| #!/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 |