xref: /linux/tools/testing/selftests/hid/tests/base.py (revision e91c37f1)
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