diff options
Diffstat (limited to 'tools/testing/selftests/hid/tests/base.py')
-rw-r--r-- | tools/testing/selftests/hid/tests/base.py | 345 |
1 files changed, 345 insertions, 0 deletions
diff --git a/tools/testing/selftests/hid/tests/base.py b/tools/testing/selftests/hid/tests/base.py new file mode 100644 index 000000000000..1305cfc9646e --- /dev/null +++ b/tools/testing/selftests/hid/tests/base.py @@ -0,0 +1,345 @@ +#!/bin/env python3 +# SPDX-License-Identifier: GPL-2.0 +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017 Benjamin Tissoires <benjamin.tissoires@gmail.com> +# Copyright (c) 2017 Red Hat, Inc. + +import libevdev +import os +import pytest +import time + +import logging + +from hidtools.device.base_device import BaseDevice, EvdevMatch, SysfsFile +from pathlib import Path +from typing import Final + +logger = logging.getLogger("hidtools.test.base") + +# application to matches +application_matches: Final = { + # pyright: ignore + "Accelerometer": EvdevMatch( + req_properties=[ + libevdev.INPUT_PROP_ACCELEROMETER, + ] + ), + "Game Pad": EvdevMatch( # in systemd, this is a lot more complex, but that will do + requires=[ + libevdev.EV_ABS.ABS_X, + libevdev.EV_ABS.ABS_Y, + libevdev.EV_ABS.ABS_RX, + libevdev.EV_ABS.ABS_RY, + libevdev.EV_KEY.BTN_START, + ], + excl_properties=[ + libevdev.INPUT_PROP_ACCELEROMETER, + ], + ), + "Joystick": EvdevMatch( # in systemd, this is a lot more complex, but that will do + requires=[ + libevdev.EV_ABS.ABS_RX, + libevdev.EV_ABS.ABS_RY, + libevdev.EV_KEY.BTN_START, + ], + excl_properties=[ + libevdev.INPUT_PROP_ACCELEROMETER, + ], + ), + "Key": EvdevMatch( + requires=[ + libevdev.EV_KEY.KEY_A, + ], + excl_properties=[ + libevdev.INPUT_PROP_ACCELEROMETER, + libevdev.INPUT_PROP_DIRECT, + libevdev.INPUT_PROP_POINTER, + ], + ), + "Mouse": EvdevMatch( + requires=[ + libevdev.EV_REL.REL_X, + libevdev.EV_REL.REL_Y, + libevdev.EV_KEY.BTN_LEFT, + ], + excl_properties=[ + libevdev.INPUT_PROP_ACCELEROMETER, + ], + ), + "Pad": EvdevMatch( + requires=[ + libevdev.EV_KEY.BTN_0, + ], + excludes=[ + libevdev.EV_KEY.BTN_TOOL_PEN, + libevdev.EV_KEY.BTN_TOUCH, + libevdev.EV_ABS.ABS_DISTANCE, + ], + excl_properties=[ + libevdev.INPUT_PROP_ACCELEROMETER, + ], + ), + "Pen": EvdevMatch( + requires=[ + libevdev.EV_KEY.BTN_STYLUS, + libevdev.EV_ABS.ABS_X, + libevdev.EV_ABS.ABS_Y, + ], + excl_properties=[ + libevdev.INPUT_PROP_ACCELEROMETER, + ], + ), + "Stylus": EvdevMatch( + requires=[ + libevdev.EV_KEY.BTN_STYLUS, + libevdev.EV_ABS.ABS_X, + libevdev.EV_ABS.ABS_Y, + ], + excl_properties=[ + libevdev.INPUT_PROP_ACCELEROMETER, + ], + ), + "Touch Pad": EvdevMatch( + requires=[ + libevdev.EV_KEY.BTN_LEFT, + libevdev.EV_ABS.ABS_X, + libevdev.EV_ABS.ABS_Y, + ], + excludes=[libevdev.EV_KEY.BTN_TOOL_PEN, libevdev.EV_KEY.BTN_STYLUS], + req_properties=[ + libevdev.INPUT_PROP_POINTER, + ], + excl_properties=[ + libevdev.INPUT_PROP_ACCELEROMETER, + ], + ), + "Touch Screen": EvdevMatch( + requires=[ + libevdev.EV_KEY.BTN_TOUCH, + libevdev.EV_ABS.ABS_X, + libevdev.EV_ABS.ABS_Y, + ], + excludes=[libevdev.EV_KEY.BTN_TOOL_PEN, libevdev.EV_KEY.BTN_STYLUS], + req_properties=[ + libevdev.INPUT_PROP_DIRECT, + ], + excl_properties=[ + libevdev.INPUT_PROP_ACCELEROMETER, + ], + ), +} + + +class UHIDTestDevice(BaseDevice): + def __init__(self, name, application, rdesc_str=None, rdesc=None, input_info=None): + super().__init__(name, application, rdesc_str, rdesc, input_info) + self.application_matches = application_matches + if name is None: + name = f"uhid test {self.__class__.__name__}" + if not name.startswith("uhid test "): + name = "uhid test " + self.name + self.name = name + + +class BaseTestCase: + class TestUhid(object): + syn_event = libevdev.InputEvent(libevdev.EV_SYN.SYN_REPORT) # type: ignore + key_event = libevdev.InputEvent(libevdev.EV_KEY) # type: ignore + abs_event = libevdev.InputEvent(libevdev.EV_ABS) # type: ignore + rel_event = libevdev.InputEvent(libevdev.EV_REL) # type: ignore + msc_event = libevdev.InputEvent(libevdev.EV_MSC.MSC_SCAN) # type: ignore + + # List of kernel modules to load before starting the test + # if any module is not available (not compiled), the test will skip. + # Each element is a tuple '(kernel driver name, kernel module)', + # for example ("playstation", "hid-playstation") + kernel_modules = [] + + def assertInputEventsIn(self, expected_events, effective_events): + effective_events = effective_events.copy() + for ev in expected_events: + assert ev in effective_events + effective_events.remove(ev) + return effective_events + + def assertInputEvents(self, expected_events, effective_events): + remaining = self.assertInputEventsIn(expected_events, effective_events) + assert remaining == [] + + @classmethod + def debug_reports(cls, reports, uhdev=None, events=None): + data = [" ".join([f"{v:02x}" for v in r]) for r in reports] + + if uhdev is not None: + human_data = [ + uhdev.parsed_rdesc.format_report(r, split_lines=True) + for r in reports + ] + try: + human_data = [ + f'\n\t {" " * h.index("/")}'.join(h.split("\n")) + for h in human_data + ] + except ValueError: + # '/' not found: not a numbered report + human_data = ["\n\t ".join(h.split("\n")) for h in human_data] + data = [f"{d}\n\t ====> {h}" for d, h in zip(data, human_data)] + + reports = data + + if len(reports) == 1: + print("sending 1 report:") + else: + print(f"sending {len(reports)} reports:") + for report in reports: + print("\t", report) + + if events is not None: + print("events received:", events) + + def create_device(self): + raise Exception("please reimplement me in subclasses") + + def _load_kernel_module(self, kernel_driver, kernel_module): + sysfs_path = Path("/sys/bus/hid/drivers") + if kernel_driver is not None: + sysfs_path /= kernel_driver + else: + # special case for when testing all available modules: + # we don't know beforehand the name of the module from modinfo + sysfs_path = Path("/sys/module") / kernel_module.replace("-", "_") + if not sysfs_path.exists(): + import subprocess + + ret = subprocess.run(["/usr/sbin/modprobe", kernel_module]) + if ret.returncode != 0: + pytest.skip( + f"module {kernel_module} could not be loaded, skipping the test" + ) + + @pytest.fixture() + def load_kernel_module(self): + for kernel_driver, kernel_module in self.kernel_modules: + self._load_kernel_module(kernel_driver, kernel_module) + yield + + @pytest.fixture() + def new_uhdev(self, load_kernel_module): + return self.create_device() + + def assertName(self, uhdev): + evdev = uhdev.get_evdev() + assert uhdev.name in evdev.name + + @pytest.fixture(autouse=True) + def context(self, new_uhdev, request): + try: + with HIDTestUdevRule.instance(): + with new_uhdev as self.uhdev: + skip_cond = request.node.get_closest_marker("skip_if_uhdev") + if skip_cond: + test, message, *rest = skip_cond.args + + if test(self.uhdev): + pytest.skip(message) + + self.uhdev.create_kernel_device() + now = time.time() + while not self.uhdev.is_ready() and time.time() - now < 5: + self.uhdev.dispatch(1) + if self.uhdev.get_evdev() is None: + logger.warning( + f"available list of input nodes: (default application is '{self.uhdev.application}')" + ) + logger.warning(self.uhdev.input_nodes) + yield + self.uhdev = None + except PermissionError: + pytest.skip("Insufficient permissions, run me as root") + + @pytest.fixture(autouse=True) + def check_taint(self): + # we are abusing SysfsFile here, it's in /proc, but meh + taint_file = SysfsFile("/proc/sys/kernel/tainted") + taint = taint_file.int_value + + yield + + assert taint_file.int_value == taint + + def test_creation(self): + """Make sure the device gets processed by the kernel and creates + the expected application input node. + + If this fail, there is something wrong in the device report + descriptors.""" + uhdev = self.uhdev + assert uhdev is not None + assert uhdev.get_evdev() is not None + self.assertName(uhdev) + assert len(uhdev.next_sync_events()) == 0 + assert uhdev.get_evdev() is not None + + +class HIDTestUdevRule(object): + _instance = None + """ + A context-manager compatible class that sets up our udev rules file and + deletes it on context exit. + + This class is tailored to our test setup: it only sets up the udev rule + on the **second** context and it cleans it up again on the last context + removed. This matches the expected pytest setup: we enter a context for + the session once, then once for each test (the first of which will + trigger the udev rule) and once the last test exited and the session + exited, we clean up after ourselves. + """ + + def __init__(self): + self.refs = 0 + self.rulesfile = None + + def __enter__(self): + self.refs += 1 + if self.refs == 2 and self.rulesfile is None: + self.create_udev_rule() + self.reload_udev_rules() + + def __exit__(self, exc_type, exc_value, traceback): + self.refs -= 1 + if self.refs == 0 and self.rulesfile: + os.remove(self.rulesfile.name) + self.reload_udev_rules() + + def reload_udev_rules(self): + import subprocess + + subprocess.run("udevadm control --reload-rules".split()) + subprocess.run("systemd-hwdb update".split()) + + def create_udev_rule(self): + import tempfile + + os.makedirs("/run/udev/rules.d", exist_ok=True) + with tempfile.NamedTemporaryFile( + prefix="91-uhid-test-device-REMOVEME-", + suffix=".rules", + mode="w+", + dir="/run/udev/rules.d", + delete=False, + ) as f: + f.write( + 'KERNELS=="*input*", ATTRS{name}=="*uhid test *", ENV{LIBINPUT_IGNORE_DEVICE}="1"\n' + ) + f.write( + 'KERNELS=="*input*", ATTRS{name}=="*uhid test * System Multi Axis", ENV{ID_INPUT_TOUCHSCREEN}="", ENV{ID_INPUT_SYSTEM_MULTIAXIS}="1"\n' + ) + self.rulesfile = f + + @classmethod + def instance(cls): + if not cls._instance: + cls._instance = HIDTestUdevRule() + return cls._instance |