419 lines · python
1#!/bin/env python32# SPDX-License-Identifier: GPL-2.03# -*- coding: utf-8 -*-4#5# Copyright (c) 2017 Benjamin Tissoires <benjamin.tissoires@gmail.com>6# Copyright (c) 2017 Red Hat, Inc.7 8import libevdev9import os10import pytest11import shutil12import subprocess13import time14 15import logging16 17from .base_device import BaseDevice, EvdevMatch, SysfsFile18from pathlib import Path19from typing import Final, List, Tuple20 21logger = logging.getLogger("hidtools.test.base")22 23# application to matches24application_matches: Final = {25 # pyright: ignore26 "Accelerometer": EvdevMatch(27 req_properties=[28 libevdev.INPUT_PROP_ACCELEROMETER,29 ]30 ),31 "Game Pad": EvdevMatch( # in systemd, this is a lot more complex, but that will do32 requires=[33 libevdev.EV_ABS.ABS_X,34 libevdev.EV_ABS.ABS_Y,35 libevdev.EV_ABS.ABS_RX,36 libevdev.EV_ABS.ABS_RY,37 libevdev.EV_KEY.BTN_START,38 ],39 excl_properties=[40 libevdev.INPUT_PROP_ACCELEROMETER,41 ],42 ),43 "Joystick": EvdevMatch( # in systemd, this is a lot more complex, but that will do44 requires=[45 libevdev.EV_ABS.ABS_RX,46 libevdev.EV_ABS.ABS_RY,47 libevdev.EV_KEY.BTN_START,48 ],49 excl_properties=[50 libevdev.INPUT_PROP_ACCELEROMETER,51 ],52 ),53 "Key": EvdevMatch(54 requires=[55 libevdev.EV_KEY.KEY_A,56 ],57 excl_properties=[58 libevdev.INPUT_PROP_ACCELEROMETER,59 libevdev.INPUT_PROP_DIRECT,60 libevdev.INPUT_PROP_POINTER,61 ],62 ),63 "Mouse": EvdevMatch(64 requires=[65 libevdev.EV_REL.REL_X,66 libevdev.EV_REL.REL_Y,67 libevdev.EV_KEY.BTN_LEFT,68 ],69 excl_properties=[70 libevdev.INPUT_PROP_ACCELEROMETER,71 ],72 ),73 "Pad": EvdevMatch(74 requires=[75 libevdev.EV_KEY.BTN_0,76 ],77 excludes=[78 libevdev.EV_KEY.BTN_TOOL_PEN,79 libevdev.EV_KEY.BTN_TOUCH,80 libevdev.EV_ABS.ABS_DISTANCE,81 ],82 excl_properties=[83 libevdev.INPUT_PROP_ACCELEROMETER,84 ],85 ),86 "Pen": EvdevMatch(87 requires=[88 libevdev.EV_KEY.BTN_STYLUS,89 libevdev.EV_ABS.ABS_X,90 libevdev.EV_ABS.ABS_Y,91 ],92 excl_properties=[93 libevdev.INPUT_PROP_ACCELEROMETER,94 ],95 ),96 "Stylus": EvdevMatch(97 requires=[98 libevdev.EV_KEY.BTN_STYLUS,99 libevdev.EV_ABS.ABS_X,100 libevdev.EV_ABS.ABS_Y,101 ],102 excl_properties=[103 libevdev.INPUT_PROP_ACCELEROMETER,104 ],105 ),106 "Touch Pad": EvdevMatch(107 requires=[108 libevdev.EV_KEY.BTN_LEFT,109 libevdev.EV_ABS.ABS_X,110 libevdev.EV_ABS.ABS_Y,111 ],112 excludes=[libevdev.EV_KEY.BTN_TOOL_PEN, libevdev.EV_KEY.BTN_STYLUS],113 req_properties=[114 libevdev.INPUT_PROP_POINTER,115 ],116 excl_properties=[117 libevdev.INPUT_PROP_ACCELEROMETER,118 ],119 ),120 "Touch Screen": EvdevMatch(121 requires=[122 libevdev.EV_KEY.BTN_TOUCH,123 libevdev.EV_ABS.ABS_X,124 libevdev.EV_ABS.ABS_Y,125 ],126 excludes=[libevdev.EV_KEY.BTN_TOOL_PEN, libevdev.EV_KEY.BTN_STYLUS],127 req_properties=[128 libevdev.INPUT_PROP_DIRECT,129 ],130 excl_properties=[131 libevdev.INPUT_PROP_ACCELEROMETER,132 ],133 ),134}135 136 137class UHIDTestDevice(BaseDevice):138 def __init__(self, name, application, rdesc_str=None, rdesc=None, input_info=None):139 super().__init__(name, application, rdesc_str, rdesc, input_info)140 self.application_matches = application_matches141 if name is None:142 name = f"uhid test {self.__class__.__name__}"143 if not name.startswith("uhid test "):144 name = "uhid test " + self.name145 self.name = name146 147 148class BaseTestCase:149 class TestUhid(object):150 syn_event = libevdev.InputEvent(libevdev.EV_SYN.SYN_REPORT) # type: ignore151 key_event = libevdev.InputEvent(libevdev.EV_KEY) # type: ignore152 abs_event = libevdev.InputEvent(libevdev.EV_ABS) # type: ignore153 rel_event = libevdev.InputEvent(libevdev.EV_REL) # type: ignore154 msc_event = libevdev.InputEvent(libevdev.EV_MSC.MSC_SCAN) # type: ignore155 156 # List of kernel modules to load before starting the test157 # if any module is not available (not compiled), the test will skip.158 # Each element is a tuple '(kernel driver name, kernel module)',159 # for example ("playstation", "hid-playstation")160 kernel_modules: List[Tuple[str, str]] = []161 162 # List of in kernel HID-BPF object files to load163 # before starting the test164 # Any existing pre-loaded HID-BPF module will be removed165 # before the ones in this list will be manually loaded.166 # Each Element is a tuple '(hid_bpf_object, rdesc_fixup_present)',167 # for example '("xppen-ArtistPro16Gen2.bpf.o", True)'168 # If 'rdesc_fixup_present' is True, the test needs to wait169 # for one unbind and rebind before it can be sure the kernel is170 # ready171 hid_bpfs: List[Tuple[str, bool]] = []172 173 def assertInputEventsIn(self, expected_events, effective_events):174 effective_events = effective_events.copy()175 for ev in expected_events:176 assert ev in effective_events177 effective_events.remove(ev)178 return effective_events179 180 def assertInputEvents(self, expected_events, effective_events):181 remaining = self.assertInputEventsIn(expected_events, effective_events)182 assert remaining == []183 184 @classmethod185 def debug_reports(cls, reports, uhdev=None, events=None):186 data = [" ".join([f"{v:02x}" for v in r]) for r in reports]187 188 if uhdev is not None:189 human_data = [190 uhdev.parsed_rdesc.format_report(r, split_lines=True)191 for r in reports192 ]193 try:194 human_data = [195 f'\n\t {" " * h.index("/")}'.join(h.split("\n"))196 for h in human_data197 ]198 except ValueError:199 # '/' not found: not a numbered report200 human_data = ["\n\t ".join(h.split("\n")) for h in human_data]201 data = [f"{d}\n\t ====> {h}" for d, h in zip(data, human_data)]202 203 reports = data204 205 if len(reports) == 1:206 print("sending 1 report:")207 else:208 print(f"sending {len(reports)} reports:")209 for report in reports:210 print("\t", report)211 212 if events is not None:213 print("events received:", events)214 215 def create_device(self):216 raise Exception("please reimplement me in subclasses")217 218 def _load_kernel_module(self, kernel_driver, kernel_module):219 sysfs_path = Path("/sys/bus/hid/drivers")220 if kernel_driver is not None:221 sysfs_path /= kernel_driver222 else:223 # special case for when testing all available modules:224 # we don't know beforehand the name of the module from modinfo225 sysfs_path = Path("/sys/module") / kernel_module.replace("-", "_")226 if not sysfs_path.exists():227 ret = subprocess.run(["/usr/sbin/modprobe", kernel_module])228 if ret.returncode != 0:229 pytest.skip(230 f"module {kernel_module} could not be loaded, skipping the test"231 )232 233 @pytest.fixture()234 def load_kernel_module(self):235 for kernel_driver, kernel_module in self.kernel_modules:236 self._load_kernel_module(kernel_driver, kernel_module)237 yield238 239 def load_hid_bpfs(self):240 script_dir = Path(os.path.dirname(os.path.realpath(__file__)))241 root_dir = (script_dir / "../../../../..").resolve()242 bpf_dir = root_dir / "drivers/hid/bpf/progs"243 244 udev_hid_bpf = shutil.which("udev-hid-bpf")245 if not udev_hid_bpf:246 pytest.skip("udev-hid-bpf not found in $PATH, skipping")247 248 wait = False249 for _, rdesc_fixup in self.hid_bpfs:250 if rdesc_fixup:251 wait = True252 253 for hid_bpf, _ in self.hid_bpfs:254 # We need to start `udev-hid-bpf` in the background255 # and dispatch uhid events in case the kernel needs256 # to fetch features on the device257 process = subprocess.Popen(258 [259 "udev-hid-bpf",260 "--verbose",261 "add",262 str(self.uhdev.sys_path),263 str(bpf_dir / hid_bpf),264 ],265 )266 while process.poll() is None:267 self.uhdev.dispatch(1)268 269 if process.poll() != 0:270 pytest.fail(271 f"Couldn't insert hid-bpf program '{hid_bpf}', marking the test as failed"272 )273 274 if wait:275 # the HID-BPF program exports a rdesc fixup, so it needs to be276 # unbound by the kernel and then rebound.277 # Ensure we get the bound event exactly 2 times (one for the normal278 # uhid loading, and then the reload from HID-BPF)279 now = time.time()280 while self.uhdev.kernel_ready_count < 2 and time.time() - now < 2:281 self.uhdev.dispatch(1)282 283 if self.uhdev.kernel_ready_count < 2:284 pytest.fail(285 f"Couldn't insert hid-bpf programs, marking the test as failed"286 )287 288 def unload_hid_bpfs(self):289 ret = subprocess.run(290 ["udev-hid-bpf", "--verbose", "remove", str(self.uhdev.sys_path)],291 )292 if ret.returncode != 0:293 pytest.fail(294 f"Couldn't unload hid-bpf programs, marking the test as failed"295 )296 297 @pytest.fixture()298 def new_uhdev(self, load_kernel_module):299 return self.create_device()300 301 def assertName(self, uhdev):302 evdev = uhdev.get_evdev()303 assert uhdev.name in evdev.name304 305 @pytest.fixture(autouse=True)306 def context(self, new_uhdev, request):307 try:308 with HIDTestUdevRule.instance():309 with new_uhdev as self.uhdev:310 for skip_cond in request.node.iter_markers("skip_if_uhdev"):311 test, message, *rest = skip_cond.args312 313 if test(self.uhdev):314 pytest.skip(message)315 316 self.uhdev.create_kernel_device()317 now = time.time()318 while not self.uhdev.is_ready() and time.time() - now < 5:319 self.uhdev.dispatch(1)320 321 if self.hid_bpfs:322 self.load_hid_bpfs()323 324 if self.uhdev.get_evdev() is None:325 logger.warning(326 f"available list of input nodes: (default application is '{self.uhdev.application}')"327 )328 logger.warning(self.uhdev.input_nodes)329 yield330 if self.hid_bpfs:331 self.unload_hid_bpfs()332 self.uhdev = None333 except PermissionError:334 pytest.skip("Insufficient permissions, run me as root")335 336 @pytest.fixture(autouse=True)337 def check_taint(self):338 # we are abusing SysfsFile here, it's in /proc, but meh339 taint_file = SysfsFile("/proc/sys/kernel/tainted")340 taint = taint_file.int_value341 342 yield343 344 assert taint_file.int_value == taint345 346 def test_creation(self):347 """Make sure the device gets processed by the kernel and creates348 the expected application input node.349 350 If this fail, there is something wrong in the device report351 descriptors."""352 uhdev = self.uhdev353 assert uhdev is not None354 assert uhdev.get_evdev() is not None355 self.assertName(uhdev)356 assert len(uhdev.next_sync_events()) == 0357 assert uhdev.get_evdev() is not None358 359 360class HIDTestUdevRule(object):361 _instance = None362 """363 A context-manager compatible class that sets up our udev rules file and364 deletes it on context exit.365 366 This class is tailored to our test setup: it only sets up the udev rule367 on the **second** context and it cleans it up again on the last context368 removed. This matches the expected pytest setup: we enter a context for369 the session once, then once for each test (the first of which will370 trigger the udev rule) and once the last test exited and the session371 exited, we clean up after ourselves.372 """373 374 def __init__(self):375 self.refs = 0376 self.rulesfile = None377 378 def __enter__(self):379 self.refs += 1380 if self.refs == 2 and self.rulesfile is None:381 self.create_udev_rule()382 self.reload_udev_rules()383 384 def __exit__(self, exc_type, exc_value, traceback):385 self.refs -= 1386 if self.refs == 0 and self.rulesfile:387 os.remove(self.rulesfile.name)388 self.reload_udev_rules()389 390 def reload_udev_rules(self):391 subprocess.run("udevadm control --reload-rules".split())392 subprocess.run("systemd-hwdb update".split())393 394 def create_udev_rule(self):395 import tempfile396 397 os.makedirs("/run/udev/rules.d", exist_ok=True)398 with tempfile.NamedTemporaryFile(399 prefix="91-uhid-test-device-REMOVEME-",400 suffix=".rules",401 mode="w+",402 dir="/run/udev/rules.d",403 delete=False,404 ) as f:405 f.write(406 """407KERNELS=="*input*", ATTRS{name}=="*uhid test *", ENV{LIBINPUT_IGNORE_DEVICE}="1"408KERNELS=="*hid*", ENV{HID_NAME}=="*uhid test *", ENV{HID_BPF_IGNORE_DEVICE}="1"409KERNELS=="*input*", ATTRS{name}=="*uhid test * System Multi Axis", ENV{ID_INPUT_TOUCHSCREEN}="", ENV{ID_INPUT_SYSTEM_MULTIAXIS}="1"410"""411 )412 self.rulesfile = f413 414 @classmethod415 def instance(cls):416 if not cls._instance:417 cls._instance = HIDTestUdevRule()418 return cls._instance419