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 dataclasses 9import libevdev 10import os 11import pytest 12import shutil 13import subprocess 14import time 15 16import logging 17 18from .base_device import BaseDevice, EvdevMatch, SysfsFile 19from pathlib import Path 20from typing import Final, List, Tuple 21 22logger = logging.getLogger("hidtools.test.base") 23 24# application to matches 25application_matches: Final = { 26 # pyright: ignore 27 "Accelerometer": EvdevMatch( 28 req_properties=[ 29 libevdev.INPUT_PROP_ACCELEROMETER, 30 ] 31 ), 32 "Game Pad": EvdevMatch( # in systemd, this is a lot more complex, but that will do 33 requires=[ 34 libevdev.EV_ABS.ABS_X, 35 libevdev.EV_ABS.ABS_Y, 36 libevdev.EV_ABS.ABS_RX, 37 libevdev.EV_ABS.ABS_RY, 38 libevdev.EV_KEY.BTN_START, 39 ], 40 excl_properties=[ 41 libevdev.INPUT_PROP_ACCELEROMETER, 42 ], 43 ), 44 "Joystick": EvdevMatch( # in systemd, this is a lot more complex, but that will do 45 requires=[ 46 libevdev.EV_ABS.ABS_RX, 47 libevdev.EV_ABS.ABS_RY, 48 libevdev.EV_KEY.BTN_START, 49 ], 50 excl_properties=[ 51 libevdev.INPUT_PROP_ACCELEROMETER, 52 ], 53 ), 54 "Key": EvdevMatch( 55 requires=[ 56 libevdev.EV_KEY.KEY_A, 57 ], 58 excl_properties=[ 59 libevdev.INPUT_PROP_ACCELEROMETER, 60 libevdev.INPUT_PROP_DIRECT, 61 libevdev.INPUT_PROP_POINTER, 62 ], 63 ), 64 "Mouse": EvdevMatch( 65 requires=[ 66 libevdev.EV_REL.REL_X, 67 libevdev.EV_REL.REL_Y, 68 libevdev.EV_KEY.BTN_LEFT, 69 ], 70 excl_properties=[ 71 libevdev.INPUT_PROP_ACCELEROMETER, 72 ], 73 ), 74 "Pad": EvdevMatch( 75 requires=[ 76 libevdev.EV_KEY.BTN_0, 77 ], 78 excludes=[ 79 libevdev.EV_KEY.BTN_TOOL_PEN, 80 libevdev.EV_KEY.BTN_TOUCH, 81 libevdev.EV_ABS.ABS_DISTANCE, 82 ], 83 excl_properties=[ 84 libevdev.INPUT_PROP_ACCELEROMETER, 85 ], 86 ), 87 "Pen": EvdevMatch( 88 requires=[ 89 libevdev.EV_KEY.BTN_STYLUS, 90 libevdev.EV_ABS.ABS_X, 91 libevdev.EV_ABS.ABS_Y, 92 ], 93 excl_properties=[ 94 libevdev.INPUT_PROP_ACCELEROMETER, 95 ], 96 ), 97 "Stylus": EvdevMatch( 98 requires=[ 99 libevdev.EV_KEY.BTN_STYLUS, 100 libevdev.EV_ABS.ABS_X, 101 libevdev.EV_ABS.ABS_Y, 102 ], 103 excl_properties=[ 104 libevdev.INPUT_PROP_ACCELEROMETER, 105 ], 106 ), 107 "Touch Pad": EvdevMatch( 108 requires=[ 109 libevdev.EV_KEY.BTN_LEFT, 110 libevdev.EV_ABS.ABS_X, 111 libevdev.EV_ABS.ABS_Y, 112 ], 113 excludes=[libevdev.EV_KEY.BTN_TOOL_PEN, libevdev.EV_KEY.BTN_STYLUS], 114 req_properties=[ 115 libevdev.INPUT_PROP_POINTER, 116 ], 117 excl_properties=[ 118 libevdev.INPUT_PROP_ACCELEROMETER, 119 ], 120 ), 121 "Touch Screen": EvdevMatch( 122 requires=[ 123 libevdev.EV_KEY.BTN_TOUCH, 124 libevdev.EV_ABS.ABS_X, 125 libevdev.EV_ABS.ABS_Y, 126 ], 127 excludes=[libevdev.EV_KEY.BTN_TOOL_PEN, libevdev.EV_KEY.BTN_STYLUS], 128 req_properties=[ 129 libevdev.INPUT_PROP_DIRECT, 130 ], 131 excl_properties=[ 132 libevdev.INPUT_PROP_ACCELEROMETER, 133 ], 134 ), 135} 136 137 138class UHIDTestDevice(BaseDevice): 139 def __init__(self, name, application, rdesc_str=None, rdesc=None, input_info=None): 140 super().__init__(name, application, rdesc_str, rdesc, input_info) 141 self.application_matches = application_matches 142 if name is None: 143 name = f"uhid test {self.__class__.__name__}" 144 if not name.startswith("uhid test "): 145 name = "uhid test " + self.name 146 self.name = name 147 148 149@dataclasses.dataclass 150class HidBpf: 151 object_name: str 152 has_rdesc_fixup: bool 153 154 155@dataclasses.dataclass 156class KernelModule: 157 driver_name: str 158 module_name: str 159 160 161class BaseTestCase: 162 class TestUhid(object): 163 syn_event = libevdev.InputEvent(libevdev.EV_SYN.SYN_REPORT) # type: ignore 164 key_event = libevdev.InputEvent(libevdev.EV_KEY) # type: ignore 165 abs_event = libevdev.InputEvent(libevdev.EV_ABS) # type: ignore 166 rel_event = libevdev.InputEvent(libevdev.EV_REL) # type: ignore 167 msc_event = libevdev.InputEvent(libevdev.EV_MSC.MSC_SCAN) # type: ignore 168 169 # List of kernel modules to load before starting the test 170 # if any module is not available (not compiled), the test will skip. 171 # Each element is a KernelModule object, for example 172 # KernelModule("playstation", "hid-playstation") 173 kernel_modules: List[KernelModule] = [] 174 175 # List of in kernel HID-BPF object files to load 176 # before starting the test 177 # Any existing pre-loaded HID-BPF module will be removed 178 # before the ones in this list will be manually loaded. 179 # Each Element is a HidBpf object, for example 180 # 'HidBpf("xppen-ArtistPro16Gen2.bpf.o", True)' 181 # If 'has_rdesc_fixup' is True, the test needs to wait 182 # for one unbind and rebind before it can be sure the kernel is 183 # ready 184 hid_bpfs: List[HidBpf] = [] 185 186 def assertInputEventsIn(self, expected_events, effective_events): 187 effective_events = effective_events.copy() 188 for ev in expected_events: 189 assert ev in effective_events 190 effective_events.remove(ev) 191 return effective_events 192 193 def assertInputEvents(self, expected_events, effective_events): 194 remaining = self.assertInputEventsIn(expected_events, effective_events) 195 assert remaining == [] 196 197 @classmethod 198 def debug_reports(cls, reports, uhdev=None, events=None): 199 data = [" ".join([f"{v:02x}" for v in r]) for r in reports] 200 201 if uhdev is not None: 202 human_data = [ 203 uhdev.parsed_rdesc.format_report(r, split_lines=True) 204 for r in reports 205 ] 206 try: 207 human_data = [ 208 f'\n\t {" " * h.index("/")}'.join(h.split("\n")) 209 for h in human_data 210 ] 211 except ValueError: 212 # '/' not found: not a numbered report 213 human_data = ["\n\t ".join(h.split("\n")) for h in human_data] 214 data = [f"{d}\n\t ====> {h}" for d, h in zip(data, human_data)] 215 216 reports = data 217 218 if len(reports) == 1: 219 print("sending 1 report:") 220 else: 221 print(f"sending {len(reports)} reports:") 222 for report in reports: 223 print("\t", report) 224 225 if events is not None: 226 print("events received:", events) 227 228 def create_device(self): 229 raise Exception("please reimplement me in subclasses") 230 231 def _load_kernel_module(self, kernel_driver, kernel_module): 232 sysfs_path = Path("/sys/bus/hid/drivers") 233 if kernel_driver is not None: 234 sysfs_path /= kernel_driver 235 else: 236 # special case for when testing all available modules: 237 # we don't know beforehand the name of the module from modinfo 238 sysfs_path = Path("/sys/module") / kernel_module.replace("-", "_") 239 if not sysfs_path.exists(): 240 ret = subprocess.run(["/usr/sbin/modprobe", kernel_module]) 241 if ret.returncode != 0: 242 pytest.skip( 243 f"module {kernel_module} could not be loaded, skipping the test" 244 ) 245 246 @pytest.fixture() 247 def load_kernel_module(self): 248 for k in self.kernel_modules: 249 self._load_kernel_module(k.driver_name, k.module_name) 250 yield 251 252 def load_hid_bpfs(self): 253 # this function will only work when run in the kernel tree 254 script_dir = Path(os.path.dirname(os.path.realpath(__file__))) 255 root_dir = (script_dir / "../../../../..").resolve() 256 bpf_dir = root_dir / "drivers/hid/bpf/progs" 257 258 if not bpf_dir.exists(): 259 pytest.skip("looks like we are not in the kernel tree, skipping") 260 261 udev_hid_bpf = shutil.which("udev-hid-bpf") 262 if not udev_hid_bpf: 263 pytest.skip("udev-hid-bpf not found in $PATH, skipping") 264 265 wait = any(b.has_rdesc_fixup for b in self.hid_bpfs) 266 267 for hid_bpf in self.hid_bpfs: 268 # We need to start `udev-hid-bpf` in the background 269 # and dispatch uhid events in case the kernel needs 270 # to fetch features on the device 271 process = subprocess.Popen( 272 [ 273 "udev-hid-bpf", 274 "--verbose", 275 "add", 276 str(self.uhdev.sys_path), 277 str(bpf_dir / hid_bpf.object_name), 278 ], 279 ) 280 while process.poll() is None: 281 self.uhdev.dispatch(1) 282 283 if process.returncode != 0: 284 pytest.fail( 285 f"Couldn't insert hid-bpf program '{hid_bpf}', marking the test as failed" 286 ) 287 288 if wait: 289 # the HID-BPF program exports a rdesc fixup, so it needs to be 290 # unbound by the kernel and then rebound. 291 # Ensure we get the bound event exactly 2 times (one for the normal 292 # uhid loading, and then the reload from HID-BPF) 293 now = time.time() 294 while self.uhdev.kernel_ready_count < 2 and time.time() - now < 2: 295 self.uhdev.dispatch(1) 296 297 if self.uhdev.kernel_ready_count < 2: 298 pytest.fail( 299 f"Couldn't insert hid-bpf programs, marking the test as failed" 300 ) 301 302 def unload_hid_bpfs(self): 303 ret = subprocess.run( 304 ["udev-hid-bpf", "--verbose", "remove", str(self.uhdev.sys_path)], 305 ) 306 if ret.returncode != 0: 307 pytest.fail( 308 f"Couldn't unload hid-bpf programs, marking the test as failed" 309 ) 310 311 @pytest.fixture() 312 def new_uhdev(self, load_kernel_module): 313 return self.create_device() 314 315 def assertName(self, uhdev): 316 evdev = uhdev.get_evdev() 317 assert uhdev.name in evdev.name 318 319 @pytest.fixture(autouse=True) 320 def context(self, new_uhdev, request): 321 try: 322 with HIDTestUdevRule.instance(): 323 with new_uhdev as self.uhdev: 324 for skip_cond in request.node.iter_markers("skip_if_uhdev"): 325 test, message, *rest = skip_cond.args 326 327 if test(self.uhdev): 328 pytest.skip(message) 329 330 self.uhdev.create_kernel_device() 331 now = time.time() 332 while not self.uhdev.is_ready() and time.time() - now < 5: 333 self.uhdev.dispatch(1) 334 335 if self.hid_bpfs: 336 self.load_hid_bpfs() 337 338 if self.uhdev.get_evdev() is None: 339 logger.warning( 340 f"available list of input nodes: (default application is '{self.uhdev.application}')" 341 ) 342 logger.warning(self.uhdev.input_nodes) 343 yield 344 if self.hid_bpfs: 345 self.unload_hid_bpfs() 346 self.uhdev = None 347 except PermissionError: 348 pytest.skip("Insufficient permissions, run me as root") 349 350 @pytest.fixture(autouse=True) 351 def check_taint(self): 352 # we are abusing SysfsFile here, it's in /proc, but meh 353 taint_file = SysfsFile("/proc/sys/kernel/tainted") 354 taint = taint_file.int_value 355 356 yield 357 358 assert taint_file.int_value == taint 359 360 def test_creation(self): 361 """Make sure the device gets processed by the kernel and creates 362 the expected application input node. 363 364 If this fail, there is something wrong in the device report 365 descriptors.""" 366 uhdev = self.uhdev 367 assert uhdev is not None 368 assert uhdev.get_evdev() is not None 369 self.assertName(uhdev) 370 assert len(uhdev.next_sync_events()) == 0 371 assert uhdev.get_evdev() is not None 372 373 374class HIDTestUdevRule(object): 375 _instance = None 376 """ 377 A context-manager compatible class that sets up our udev rules file and 378 deletes it on context exit. 379 380 This class is tailored to our test setup: it only sets up the udev rule 381 on the **second** context and it cleans it up again on the last context 382 removed. This matches the expected pytest setup: we enter a context for 383 the session once, then once for each test (the first of which will 384 trigger the udev rule) and once the last test exited and the session 385 exited, we clean up after ourselves. 386 """ 387 388 def __init__(self): 389 self.refs = 0 390 self.rulesfile = None 391 392 def __enter__(self): 393 self.refs += 1 394 if self.refs == 2 and self.rulesfile is None: 395 self.create_udev_rule() 396 self.reload_udev_rules() 397 398 def __exit__(self, exc_type, exc_value, traceback): 399 self.refs -= 1 400 if self.refs == 0 and self.rulesfile: 401 os.remove(self.rulesfile.name) 402 self.reload_udev_rules() 403 404 def reload_udev_rules(self): 405 subprocess.run("udevadm control --reload-rules".split()) 406 subprocess.run("systemd-hwdb update".split()) 407 408 def create_udev_rule(self): 409 import tempfile 410 411 os.makedirs("/run/udev/rules.d", exist_ok=True) 412 with tempfile.NamedTemporaryFile( 413 prefix="91-uhid-test-device-REMOVEME-", 414 suffix=".rules", 415 mode="w+", 416 dir="/run/udev/rules.d", 417 delete=False, 418 ) as f: 419 f.write( 420 """ 421KERNELS=="*input*", ATTRS{name}=="*uhid test *", ENV{LIBINPUT_IGNORE_DEVICE}="1" 422KERNELS=="*hid*", ENV{HID_NAME}=="*uhid test *", ENV{HID_BPF_IGNORE_DEVICE}="1" 423KERNELS=="*input*", ATTRS{name}=="*uhid test * System Multi Axis", ENV{ID_INPUT_TOUCHSCREEN}="", ENV{ID_INPUT_SYSTEM_MULTIAXIS}="1" 424""" 425 ) 426 self.rulesfile = f 427 428 @classmethod 429 def instance(cls): 430 if not cls._instance: 431 cls._instance = HIDTestUdevRule() 432 return cls._instance 433