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 time 12 13import logging 14 15from hidtools.device.base_device import BaseDevice, EvdevMatch, SysfsFile 16from pathlib import Path 17from typing import Final, List, Tuple 18 19logger = logging.getLogger("hidtools.test.base") 20 21# application to matches 22application_matches: Final = { 23 # pyright: ignore 24 "Accelerometer": EvdevMatch( 25 req_properties=[ 26 libevdev.INPUT_PROP_ACCELEROMETER, 27 ] 28 ), 29 "Game Pad": EvdevMatch( # in systemd, this is a lot more complex, but that will do 30 requires=[ 31 libevdev.EV_ABS.ABS_X, 32 libevdev.EV_ABS.ABS_Y, 33 libevdev.EV_ABS.ABS_RX, 34 libevdev.EV_ABS.ABS_RY, 35 libevdev.EV_KEY.BTN_START, 36 ], 37 excl_properties=[ 38 libevdev.INPUT_PROP_ACCELEROMETER, 39 ], 40 ), 41 "Joystick": EvdevMatch( # in systemd, this is a lot more complex, but that will do 42 requires=[ 43 libevdev.EV_ABS.ABS_RX, 44 libevdev.EV_ABS.ABS_RY, 45 libevdev.EV_KEY.BTN_START, 46 ], 47 excl_properties=[ 48 libevdev.INPUT_PROP_ACCELEROMETER, 49 ], 50 ), 51 "Key": EvdevMatch( 52 requires=[ 53 libevdev.EV_KEY.KEY_A, 54 ], 55 excl_properties=[ 56 libevdev.INPUT_PROP_ACCELEROMETER, 57 libevdev.INPUT_PROP_DIRECT, 58 libevdev.INPUT_PROP_POINTER, 59 ], 60 ), 61 "Mouse": EvdevMatch( 62 requires=[ 63 libevdev.EV_REL.REL_X, 64 libevdev.EV_REL.REL_Y, 65 libevdev.EV_KEY.BTN_LEFT, 66 ], 67 excl_properties=[ 68 libevdev.INPUT_PROP_ACCELEROMETER, 69 ], 70 ), 71 "Pad": EvdevMatch( 72 requires=[ 73 libevdev.EV_KEY.BTN_0, 74 ], 75 excludes=[ 76 libevdev.EV_KEY.BTN_TOOL_PEN, 77 libevdev.EV_KEY.BTN_TOUCH, 78 libevdev.EV_ABS.ABS_DISTANCE, 79 ], 80 excl_properties=[ 81 libevdev.INPUT_PROP_ACCELEROMETER, 82 ], 83 ), 84 "Pen": EvdevMatch( 85 requires=[ 86 libevdev.EV_KEY.BTN_STYLUS, 87 libevdev.EV_ABS.ABS_X, 88 libevdev.EV_ABS.ABS_Y, 89 ], 90 excl_properties=[ 91 libevdev.INPUT_PROP_ACCELEROMETER, 92 ], 93 ), 94 "Stylus": EvdevMatch( 95 requires=[ 96 libevdev.EV_KEY.BTN_STYLUS, 97 libevdev.EV_ABS.ABS_X, 98 libevdev.EV_ABS.ABS_Y, 99 ], 100 excl_properties=[ 101 libevdev.INPUT_PROP_ACCELEROMETER, 102 ], 103 ), 104 "Touch Pad": EvdevMatch( 105 requires=[ 106 libevdev.EV_KEY.BTN_LEFT, 107 libevdev.EV_ABS.ABS_X, 108 libevdev.EV_ABS.ABS_Y, 109 ], 110 excludes=[libevdev.EV_KEY.BTN_TOOL_PEN, libevdev.EV_KEY.BTN_STYLUS], 111 req_properties=[ 112 libevdev.INPUT_PROP_POINTER, 113 ], 114 excl_properties=[ 115 libevdev.INPUT_PROP_ACCELEROMETER, 116 ], 117 ), 118 "Touch Screen": EvdevMatch( 119 requires=[ 120 libevdev.EV_KEY.BTN_TOUCH, 121 libevdev.EV_ABS.ABS_X, 122 libevdev.EV_ABS.ABS_Y, 123 ], 124 excludes=[libevdev.EV_KEY.BTN_TOOL_PEN, libevdev.EV_KEY.BTN_STYLUS], 125 req_properties=[ 126 libevdev.INPUT_PROP_DIRECT, 127 ], 128 excl_properties=[ 129 libevdev.INPUT_PROP_ACCELEROMETER, 130 ], 131 ), 132} 133 134 135class UHIDTestDevice(BaseDevice): 136 def __init__(self, name, application, rdesc_str=None, rdesc=None, input_info=None): 137 super().__init__(name, application, rdesc_str, rdesc, input_info) 138 self.application_matches = application_matches 139 if name is None: 140 name = f"uhid test {self.__class__.__name__}" 141 if not name.startswith("uhid test "): 142 name = "uhid test " + self.name 143 self.name = name 144 145 146class BaseTestCase: 147 class TestUhid(object): 148 syn_event = libevdev.InputEvent(libevdev.EV_SYN.SYN_REPORT) # type: ignore 149 key_event = libevdev.InputEvent(libevdev.EV_KEY) # type: ignore 150 abs_event = libevdev.InputEvent(libevdev.EV_ABS) # type: ignore 151 rel_event = libevdev.InputEvent(libevdev.EV_REL) # type: ignore 152 msc_event = libevdev.InputEvent(libevdev.EV_MSC.MSC_SCAN) # type: ignore 153 154 # List of kernel modules to load before starting the test 155 # if any module is not available (not compiled), the test will skip. 156 # Each element is a tuple '(kernel driver name, kernel module)', 157 # for example ("playstation", "hid-playstation") 158 kernel_modules: List[Tuple[str, str]] = [] 159 160 def assertInputEventsIn(self, expected_events, effective_events): 161 effective_events = effective_events.copy() 162 for ev in expected_events: 163 assert ev in effective_events 164 effective_events.remove(ev) 165 return effective_events 166 167 def assertInputEvents(self, expected_events, effective_events): 168 remaining = self.assertInputEventsIn(expected_events, effective_events) 169 assert remaining == [] 170 171 @classmethod 172 def debug_reports(cls, reports, uhdev=None, events=None): 173 data = [" ".join([f"{v:02x}" for v in r]) for r in reports] 174 175 if uhdev is not None: 176 human_data = [ 177 uhdev.parsed_rdesc.format_report(r, split_lines=True) 178 for r in reports 179 ] 180 try: 181 human_data = [ 182 f'\n\t {" " * h.index("/")}'.join(h.split("\n")) 183 for h in human_data 184 ] 185 except ValueError: 186 # '/' not found: not a numbered report 187 human_data = ["\n\t ".join(h.split("\n")) for h in human_data] 188 data = [f"{d}\n\t ====> {h}" for d, h in zip(data, human_data)] 189 190 reports = data 191 192 if len(reports) == 1: 193 print("sending 1 report:") 194 else: 195 print(f"sending {len(reports)} reports:") 196 for report in reports: 197 print("\t", report) 198 199 if events is not None: 200 print("events received:", events) 201 202 def create_device(self): 203 raise Exception("please reimplement me in subclasses") 204 205 def _load_kernel_module(self, kernel_driver, kernel_module): 206 sysfs_path = Path("/sys/bus/hid/drivers") 207 if kernel_driver is not None: 208 sysfs_path /= kernel_driver 209 else: 210 # special case for when testing all available modules: 211 # we don't know beforehand the name of the module from modinfo 212 sysfs_path = Path("/sys/module") / kernel_module.replace("-", "_") 213 if not sysfs_path.exists(): 214 import subprocess 215 216 ret = subprocess.run(["/usr/sbin/modprobe", kernel_module]) 217 if ret.returncode != 0: 218 pytest.skip( 219 f"module {kernel_module} could not be loaded, skipping the test" 220 ) 221 222 @pytest.fixture() 223 def load_kernel_module(self): 224 for kernel_driver, kernel_module in self.kernel_modules: 225 self._load_kernel_module(kernel_driver, kernel_module) 226 yield 227 228 @pytest.fixture() 229 def new_uhdev(self, load_kernel_module): 230 return self.create_device() 231 232 def assertName(self, uhdev): 233 evdev = uhdev.get_evdev() 234 assert uhdev.name in evdev.name 235 236 @pytest.fixture(autouse=True) 237 def context(self, new_uhdev, request): 238 try: 239 with HIDTestUdevRule.instance(): 240 with new_uhdev as self.uhdev: 241 for skip_cond in request.node.iter_markers("skip_if_uhdev"): 242 test, message, *rest = skip_cond.args 243 244 if test(self.uhdev): 245 pytest.skip(message) 246 247 self.uhdev.create_kernel_device() 248 now = time.time() 249 while not self.uhdev.is_ready() and time.time() - now < 5: 250 self.uhdev.dispatch(1) 251 if self.uhdev.get_evdev() is None: 252 logger.warning( 253 f"available list of input nodes: (default application is '{self.uhdev.application}')" 254 ) 255 logger.warning(self.uhdev.input_nodes) 256 yield 257 self.uhdev = None 258 except PermissionError: 259 pytest.skip("Insufficient permissions, run me as root") 260 261 @pytest.fixture(autouse=True) 262 def check_taint(self): 263 # we are abusing SysfsFile here, it's in /proc, but meh 264 taint_file = SysfsFile("/proc/sys/kernel/tainted") 265 taint = taint_file.int_value 266 267 yield 268 269 assert taint_file.int_value == taint 270 271 def test_creation(self): 272 """Make sure the device gets processed by the kernel and creates 273 the expected application input node. 274 275 If this fail, there is something wrong in the device report 276 descriptors.""" 277 uhdev = self.uhdev 278 assert uhdev is not None 279 assert uhdev.get_evdev() is not None 280 self.assertName(uhdev) 281 assert len(uhdev.next_sync_events()) == 0 282 assert uhdev.get_evdev() is not None 283 284 285class HIDTestUdevRule(object): 286 _instance = None 287 """ 288 A context-manager compatible class that sets up our udev rules file and 289 deletes it on context exit. 290 291 This class is tailored to our test setup: it only sets up the udev rule 292 on the **second** context and it cleans it up again on the last context 293 removed. This matches the expected pytest setup: we enter a context for 294 the session once, then once for each test (the first of which will 295 trigger the udev rule) and once the last test exited and the session 296 exited, we clean up after ourselves. 297 """ 298 299 def __init__(self): 300 self.refs = 0 301 self.rulesfile = None 302 303 def __enter__(self): 304 self.refs += 1 305 if self.refs == 2 and self.rulesfile is None: 306 self.create_udev_rule() 307 self.reload_udev_rules() 308 309 def __exit__(self, exc_type, exc_value, traceback): 310 self.refs -= 1 311 if self.refs == 0 and self.rulesfile: 312 os.remove(self.rulesfile.name) 313 self.reload_udev_rules() 314 315 def reload_udev_rules(self): 316 import subprocess 317 318 subprocess.run("udevadm control --reload-rules".split()) 319 subprocess.run("systemd-hwdb update".split()) 320 321 def create_udev_rule(self): 322 import tempfile 323 324 os.makedirs("/run/udev/rules.d", exist_ok=True) 325 with tempfile.NamedTemporaryFile( 326 prefix="91-uhid-test-device-REMOVEME-", 327 suffix=".rules", 328 mode="w+", 329 dir="/run/udev/rules.d", 330 delete=False, 331 ) as f: 332 f.write( 333 'KERNELS=="*input*", ATTRS{name}=="*uhid test *", ENV{LIBINPUT_IGNORE_DEVICE}="1"\n' 334 ) 335 f.write( 336 'KERNELS=="*input*", ATTRS{name}=="*uhid test * System Multi Axis", ENV{ID_INPUT_TOUCHSCREEN}="", ENV{ID_INPUT_SYSTEM_MULTIAXIS}="1"\n' 337 ) 338 self.rulesfile = f 339 340 @classmethod 341 def instance(cls): 342 if not cls._instance: 343 cls._instance = HIDTestUdevRule() 344 return cls._instance 345