|
| 1 | +#!/bin/env python3 |
| 2 | +# SPDX-License-Identifier: GPL-2.0 |
| 3 | +# -*- coding: utf-8 -*- |
| 4 | +# |
| 5 | +# Copyright (c) 2017 Benjamin Tissoires <[email protected]> |
| 6 | +# Copyright (c) 2017 Red Hat, Inc. |
| 7 | + |
| 8 | +import libevdev |
| 9 | +import os |
| 10 | +import pytest |
| 11 | +import time |
| 12 | + |
| 13 | +import logging |
| 14 | + |
| 15 | +from hidtools.device.base_device import BaseDevice, EvdevMatch, SysfsFile |
| 16 | +from pathlib import Path |
| 17 | +from typing import Final |
| 18 | + |
| 19 | +logger = logging.getLogger("hidtools.test.base") |
| 20 | + |
| 21 | +# application to matches |
| 22 | +application_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 | + |
| 135 | +class 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 | + |
| 146 | +class 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 = [] |
| 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 | + skip_cond = request.node.get_closest_marker("skip_if_uhdev") |
| 242 | + if skip_cond: |
| 243 | + test, message, *rest = skip_cond.args |
| 244 | + |
| 245 | + if test(self.uhdev): |
| 246 | + pytest.skip(message) |
| 247 | + |
| 248 | + self.uhdev.create_kernel_device() |
| 249 | + now = time.time() |
| 250 | + while not self.uhdev.is_ready() and time.time() - now < 5: |
| 251 | + self.uhdev.dispatch(1) |
| 252 | + if self.uhdev.get_evdev() is None: |
| 253 | + logger.warning( |
| 254 | + f"available list of input nodes: (default application is '{self.uhdev.application}')" |
| 255 | + ) |
| 256 | + logger.warning(self.uhdev.input_nodes) |
| 257 | + yield |
| 258 | + self.uhdev = None |
| 259 | + except PermissionError: |
| 260 | + pytest.skip("Insufficient permissions, run me as root") |
| 261 | + |
| 262 | + @pytest.fixture(autouse=True) |
| 263 | + def check_taint(self): |
| 264 | + # we are abusing SysfsFile here, it's in /proc, but meh |
| 265 | + taint_file = SysfsFile("/proc/sys/kernel/tainted") |
| 266 | + taint = taint_file.int_value |
| 267 | + |
| 268 | + yield |
| 269 | + |
| 270 | + assert taint_file.int_value == taint |
| 271 | + |
| 272 | + def test_creation(self): |
| 273 | + """Make sure the device gets processed by the kernel and creates |
| 274 | + the expected application input node. |
| 275 | +
|
| 276 | + If this fail, there is something wrong in the device report |
| 277 | + descriptors.""" |
| 278 | + uhdev = self.uhdev |
| 279 | + assert uhdev is not None |
| 280 | + assert uhdev.get_evdev() is not None |
| 281 | + self.assertName(uhdev) |
| 282 | + assert len(uhdev.next_sync_events()) == 0 |
| 283 | + assert uhdev.get_evdev() is not None |
| 284 | + |
| 285 | + |
| 286 | +class HIDTestUdevRule(object): |
| 287 | + _instance = None |
| 288 | + """ |
| 289 | + A context-manager compatible class that sets up our udev rules file and |
| 290 | + deletes it on context exit. |
| 291 | +
|
| 292 | + This class is tailored to our test setup: it only sets up the udev rule |
| 293 | + on the **second** context and it cleans it up again on the last context |
| 294 | + removed. This matches the expected pytest setup: we enter a context for |
| 295 | + the session once, then once for each test (the first of which will |
| 296 | + trigger the udev rule) and once the last test exited and the session |
| 297 | + exited, we clean up after ourselves. |
| 298 | + """ |
| 299 | + |
| 300 | + def __init__(self): |
| 301 | + self.refs = 0 |
| 302 | + self.rulesfile = None |
| 303 | + |
| 304 | + def __enter__(self): |
| 305 | + self.refs += 1 |
| 306 | + if self.refs == 2 and self.rulesfile is None: |
| 307 | + self.create_udev_rule() |
| 308 | + self.reload_udev_rules() |
| 309 | + |
| 310 | + def __exit__(self, exc_type, exc_value, traceback): |
| 311 | + self.refs -= 1 |
| 312 | + if self.refs == 0 and self.rulesfile: |
| 313 | + os.remove(self.rulesfile.name) |
| 314 | + self.reload_udev_rules() |
| 315 | + |
| 316 | + def reload_udev_rules(self): |
| 317 | + import subprocess |
| 318 | + |
| 319 | + subprocess.run("udevadm control --reload-rules".split()) |
| 320 | + subprocess.run("systemd-hwdb update".split()) |
| 321 | + |
| 322 | + def create_udev_rule(self): |
| 323 | + import tempfile |
| 324 | + |
| 325 | + os.makedirs("/run/udev/rules.d", exist_ok=True) |
| 326 | + with tempfile.NamedTemporaryFile( |
| 327 | + prefix="91-uhid-test-device-REMOVEME-", |
| 328 | + suffix=".rules", |
| 329 | + mode="w+", |
| 330 | + dir="/run/udev/rules.d", |
| 331 | + delete=False, |
| 332 | + ) as f: |
| 333 | + f.write( |
| 334 | + 'KERNELS=="*input*", ATTRS{name}=="*uhid test *", ENV{LIBINPUT_IGNORE_DEVICE}="1"\n' |
| 335 | + ) |
| 336 | + f.write( |
| 337 | + 'KERNELS=="*input*", ATTRS{name}=="*uhid test * System Multi Axis", ENV{ID_INPUT_TOUCHSCREEN}="", ENV{ID_INPUT_SYSTEM_MULTIAXIS}="1"\n' |
| 338 | + ) |
| 339 | + self.rulesfile = f |
| 340 | + |
| 341 | + @classmethod |
| 342 | + def instance(cls): |
| 343 | + if not cls._instance: |
| 344 | + cls._instance = HIDTestUdevRule() |
| 345 | + return cls._instance |
0 commit comments