1#!/bin/env python3 2# SPDX-License-Identifier: GPL-2.0 3# -*- coding: utf-8 -*- 4# 5# Copyright (c) 2017 Benjamin Tissoires <benjamin.tissoires@gmail.com> 6# Copyright (c) 2017 Red Hat, Inc. 7 8import libevdev 9import os 10import pytest 11import shutil 12import subprocess 13import time 14 15import logging 16 17from .base_device import BaseDevice, EvdevMatch, SysfsFile 18from pathlib import Path 19from typing import Final, List, Tuple 20 21logger = logging.getLogger("hidtools.test.base") 22 23# application to matches 24application_matches: Final = { 25 # pyright: ignore 26 "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 do 32 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 do 44 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_matches 141 if name is None: 142 name = f"uhid test {self.__class__.__name__}" 143 if not name.startswith("uhid test "): 144 name = "uhid test " + self.name 145 self.name = name 146 147 148class BaseTestCase: 149 class TestUhid(object): 150 syn_event = libevdev.InputEvent(libevdev.EV_SYN.SYN_REPORT) # type: ignore 151 key_event = libevdev.InputEvent(libevdev.EV_KEY) # type: ignore 152 abs_event = libevdev.InputEvent(libevdev.EV_ABS) # type: ignore 153 rel_event = libevdev.InputEvent(libevdev.EV_REL) # type: ignore 154 msc_event = libevdev.InputEvent(libevdev.EV_MSC.MSC_SCAN) # type: ignore 155 156 # List of kernel modules to load before starting the test 157 # 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 load 163 # before starting the test 164 # Any existing pre-loaded HID-BPF module will be removed 165 # 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 wait 169 # for one unbind and rebind before it can be sure the kernel is 170 # ready 171 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_events 177 effective_events.remove(ev) 178 return effective_events 179 180 def assertInputEvents(self, expected_events, effective_events): 181 remaining = self.assertInputEventsIn(expected_events, effective_events) 182 assert remaining == [] 183 184 @classmethod 185 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 reports 192 ] 193 try: 194 human_data = [ 195 f'\n\t {" " * h.index("/")}'.join(h.split("\n")) 196 for h in human_data 197 ] 198 except ValueError: 199 # '/' not found: not a numbered report 200 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 = data 204 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_driver 222 else: 223 # special case for when testing all available modules: 224 # we don't know beforehand the name of the module from modinfo 225 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 yield 238 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 = False 249 for _, rdesc_fixup in self.hid_bpfs: 250 if rdesc_fixup: 251 wait = True 252 253 for hid_bpf, _ in self.hid_bpfs: 254 # We need to start `udev-hid-bpf` in the background 255 # and dispatch uhid events in case the kernel needs 256 # to fetch features on the device 257 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 be 276 # unbound by the kernel and then rebound. 277 # Ensure we get the bound event exactly 2 times (one for the normal 278 # 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.name 304 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.args 312 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 yield 330 if self.hid_bpfs: 331 self.unload_hid_bpfs() 332 self.uhdev = None 333 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 meh 339 taint_file = SysfsFile("/proc/sys/kernel/tainted") 340 taint = taint_file.int_value 341 342 yield 343 344 assert taint_file.int_value == taint 345 346 def test_creation(self): 347 """Make sure the device gets processed by the kernel and creates 348 the expected application input node. 349 350 If this fail, there is something wrong in the device report 351 descriptors.""" 352 uhdev = self.uhdev 353 assert uhdev is not None 354 assert uhdev.get_evdev() is not None 355 self.assertName(uhdev) 356 assert len(uhdev.next_sync_events()) == 0 357 assert uhdev.get_evdev() is not None 358 359 360class HIDTestUdevRule(object): 361 _instance = None 362 """ 363 A context-manager compatible class that sets up our udev rules file and 364 deletes it on context exit. 365 366 This class is tailored to our test setup: it only sets up the udev rule 367 on the **second** context and it cleans it up again on the last context 368 removed. This matches the expected pytest setup: we enter a context for 369 the session once, then once for each test (the first of which will 370 trigger the udev rule) and once the last test exited and the session 371 exited, we clean up after ourselves. 372 """ 373 374 def __init__(self): 375 self.refs = 0 376 self.rulesfile = None 377 378 def __enter__(self): 379 self.refs += 1 380 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 -= 1 386 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 tempfile 396 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 = f 413 414 @classmethod 415 def instance(cls): 416 if not cls._instance: 417 cls._instance = HIDTestUdevRule() 418 return cls._instance 419