162306a36Sopenharmony_ci#!/bin/env python3
262306a36Sopenharmony_ci# SPDX-License-Identifier: GPL-2.0
362306a36Sopenharmony_ci# -*- coding: utf-8 -*-
462306a36Sopenharmony_ci#
562306a36Sopenharmony_ci# Copyright (c) 2017 Benjamin Tissoires <benjamin.tissoires@gmail.com>
662306a36Sopenharmony_ci# Copyright (c) 2017 Red Hat, Inc.
762306a36Sopenharmony_ci
862306a36Sopenharmony_ciimport libevdev
962306a36Sopenharmony_ciimport os
1062306a36Sopenharmony_ciimport pytest
1162306a36Sopenharmony_ciimport time
1262306a36Sopenharmony_ci
1362306a36Sopenharmony_ciimport logging
1462306a36Sopenharmony_ci
1562306a36Sopenharmony_cifrom hidtools.device.base_device import BaseDevice, EvdevMatch, SysfsFile
1662306a36Sopenharmony_cifrom pathlib import Path
1762306a36Sopenharmony_cifrom typing import Final
1862306a36Sopenharmony_ci
1962306a36Sopenharmony_cilogger = logging.getLogger("hidtools.test.base")
2062306a36Sopenharmony_ci
2162306a36Sopenharmony_ci# application to matches
2262306a36Sopenharmony_ciapplication_matches: Final = {
2362306a36Sopenharmony_ci    # pyright: ignore
2462306a36Sopenharmony_ci    "Accelerometer": EvdevMatch(
2562306a36Sopenharmony_ci        req_properties=[
2662306a36Sopenharmony_ci            libevdev.INPUT_PROP_ACCELEROMETER,
2762306a36Sopenharmony_ci        ]
2862306a36Sopenharmony_ci    ),
2962306a36Sopenharmony_ci    "Game Pad": EvdevMatch(  # in systemd, this is a lot more complex, but that will do
3062306a36Sopenharmony_ci        requires=[
3162306a36Sopenharmony_ci            libevdev.EV_ABS.ABS_X,
3262306a36Sopenharmony_ci            libevdev.EV_ABS.ABS_Y,
3362306a36Sopenharmony_ci            libevdev.EV_ABS.ABS_RX,
3462306a36Sopenharmony_ci            libevdev.EV_ABS.ABS_RY,
3562306a36Sopenharmony_ci            libevdev.EV_KEY.BTN_START,
3662306a36Sopenharmony_ci        ],
3762306a36Sopenharmony_ci        excl_properties=[
3862306a36Sopenharmony_ci            libevdev.INPUT_PROP_ACCELEROMETER,
3962306a36Sopenharmony_ci        ],
4062306a36Sopenharmony_ci    ),
4162306a36Sopenharmony_ci    "Joystick": EvdevMatch(  # in systemd, this is a lot more complex, but that will do
4262306a36Sopenharmony_ci        requires=[
4362306a36Sopenharmony_ci            libevdev.EV_ABS.ABS_RX,
4462306a36Sopenharmony_ci            libevdev.EV_ABS.ABS_RY,
4562306a36Sopenharmony_ci            libevdev.EV_KEY.BTN_START,
4662306a36Sopenharmony_ci        ],
4762306a36Sopenharmony_ci        excl_properties=[
4862306a36Sopenharmony_ci            libevdev.INPUT_PROP_ACCELEROMETER,
4962306a36Sopenharmony_ci        ],
5062306a36Sopenharmony_ci    ),
5162306a36Sopenharmony_ci    "Key": EvdevMatch(
5262306a36Sopenharmony_ci        requires=[
5362306a36Sopenharmony_ci            libevdev.EV_KEY.KEY_A,
5462306a36Sopenharmony_ci        ],
5562306a36Sopenharmony_ci        excl_properties=[
5662306a36Sopenharmony_ci            libevdev.INPUT_PROP_ACCELEROMETER,
5762306a36Sopenharmony_ci            libevdev.INPUT_PROP_DIRECT,
5862306a36Sopenharmony_ci            libevdev.INPUT_PROP_POINTER,
5962306a36Sopenharmony_ci        ],
6062306a36Sopenharmony_ci    ),
6162306a36Sopenharmony_ci    "Mouse": EvdevMatch(
6262306a36Sopenharmony_ci        requires=[
6362306a36Sopenharmony_ci            libevdev.EV_REL.REL_X,
6462306a36Sopenharmony_ci            libevdev.EV_REL.REL_Y,
6562306a36Sopenharmony_ci            libevdev.EV_KEY.BTN_LEFT,
6662306a36Sopenharmony_ci        ],
6762306a36Sopenharmony_ci        excl_properties=[
6862306a36Sopenharmony_ci            libevdev.INPUT_PROP_ACCELEROMETER,
6962306a36Sopenharmony_ci        ],
7062306a36Sopenharmony_ci    ),
7162306a36Sopenharmony_ci    "Pad": EvdevMatch(
7262306a36Sopenharmony_ci        requires=[
7362306a36Sopenharmony_ci            libevdev.EV_KEY.BTN_0,
7462306a36Sopenharmony_ci        ],
7562306a36Sopenharmony_ci        excludes=[
7662306a36Sopenharmony_ci            libevdev.EV_KEY.BTN_TOOL_PEN,
7762306a36Sopenharmony_ci            libevdev.EV_KEY.BTN_TOUCH,
7862306a36Sopenharmony_ci            libevdev.EV_ABS.ABS_DISTANCE,
7962306a36Sopenharmony_ci        ],
8062306a36Sopenharmony_ci        excl_properties=[
8162306a36Sopenharmony_ci            libevdev.INPUT_PROP_ACCELEROMETER,
8262306a36Sopenharmony_ci        ],
8362306a36Sopenharmony_ci    ),
8462306a36Sopenharmony_ci    "Pen": EvdevMatch(
8562306a36Sopenharmony_ci        requires=[
8662306a36Sopenharmony_ci            libevdev.EV_KEY.BTN_STYLUS,
8762306a36Sopenharmony_ci            libevdev.EV_ABS.ABS_X,
8862306a36Sopenharmony_ci            libevdev.EV_ABS.ABS_Y,
8962306a36Sopenharmony_ci        ],
9062306a36Sopenharmony_ci        excl_properties=[
9162306a36Sopenharmony_ci            libevdev.INPUT_PROP_ACCELEROMETER,
9262306a36Sopenharmony_ci        ],
9362306a36Sopenharmony_ci    ),
9462306a36Sopenharmony_ci    "Stylus": EvdevMatch(
9562306a36Sopenharmony_ci        requires=[
9662306a36Sopenharmony_ci            libevdev.EV_KEY.BTN_STYLUS,
9762306a36Sopenharmony_ci            libevdev.EV_ABS.ABS_X,
9862306a36Sopenharmony_ci            libevdev.EV_ABS.ABS_Y,
9962306a36Sopenharmony_ci        ],
10062306a36Sopenharmony_ci        excl_properties=[
10162306a36Sopenharmony_ci            libevdev.INPUT_PROP_ACCELEROMETER,
10262306a36Sopenharmony_ci        ],
10362306a36Sopenharmony_ci    ),
10462306a36Sopenharmony_ci    "Touch Pad": EvdevMatch(
10562306a36Sopenharmony_ci        requires=[
10662306a36Sopenharmony_ci            libevdev.EV_KEY.BTN_LEFT,
10762306a36Sopenharmony_ci            libevdev.EV_ABS.ABS_X,
10862306a36Sopenharmony_ci            libevdev.EV_ABS.ABS_Y,
10962306a36Sopenharmony_ci        ],
11062306a36Sopenharmony_ci        excludes=[libevdev.EV_KEY.BTN_TOOL_PEN, libevdev.EV_KEY.BTN_STYLUS],
11162306a36Sopenharmony_ci        req_properties=[
11262306a36Sopenharmony_ci            libevdev.INPUT_PROP_POINTER,
11362306a36Sopenharmony_ci        ],
11462306a36Sopenharmony_ci        excl_properties=[
11562306a36Sopenharmony_ci            libevdev.INPUT_PROP_ACCELEROMETER,
11662306a36Sopenharmony_ci        ],
11762306a36Sopenharmony_ci    ),
11862306a36Sopenharmony_ci    "Touch Screen": EvdevMatch(
11962306a36Sopenharmony_ci        requires=[
12062306a36Sopenharmony_ci            libevdev.EV_KEY.BTN_TOUCH,
12162306a36Sopenharmony_ci            libevdev.EV_ABS.ABS_X,
12262306a36Sopenharmony_ci            libevdev.EV_ABS.ABS_Y,
12362306a36Sopenharmony_ci        ],
12462306a36Sopenharmony_ci        excludes=[libevdev.EV_KEY.BTN_TOOL_PEN, libevdev.EV_KEY.BTN_STYLUS],
12562306a36Sopenharmony_ci        req_properties=[
12662306a36Sopenharmony_ci            libevdev.INPUT_PROP_DIRECT,
12762306a36Sopenharmony_ci        ],
12862306a36Sopenharmony_ci        excl_properties=[
12962306a36Sopenharmony_ci            libevdev.INPUT_PROP_ACCELEROMETER,
13062306a36Sopenharmony_ci        ],
13162306a36Sopenharmony_ci    ),
13262306a36Sopenharmony_ci}
13362306a36Sopenharmony_ci
13462306a36Sopenharmony_ci
13562306a36Sopenharmony_ciclass UHIDTestDevice(BaseDevice):
13662306a36Sopenharmony_ci    def __init__(self, name, application, rdesc_str=None, rdesc=None, input_info=None):
13762306a36Sopenharmony_ci        super().__init__(name, application, rdesc_str, rdesc, input_info)
13862306a36Sopenharmony_ci        self.application_matches = application_matches
13962306a36Sopenharmony_ci        if name is None:
14062306a36Sopenharmony_ci            name = f"uhid test {self.__class__.__name__}"
14162306a36Sopenharmony_ci        if not name.startswith("uhid test "):
14262306a36Sopenharmony_ci            name = "uhid test " + self.name
14362306a36Sopenharmony_ci        self.name = name
14462306a36Sopenharmony_ci
14562306a36Sopenharmony_ci
14662306a36Sopenharmony_ciclass BaseTestCase:
14762306a36Sopenharmony_ci    class TestUhid(object):
14862306a36Sopenharmony_ci        syn_event = libevdev.InputEvent(libevdev.EV_SYN.SYN_REPORT)  # type: ignore
14962306a36Sopenharmony_ci        key_event = libevdev.InputEvent(libevdev.EV_KEY)  # type: ignore
15062306a36Sopenharmony_ci        abs_event = libevdev.InputEvent(libevdev.EV_ABS)  # type: ignore
15162306a36Sopenharmony_ci        rel_event = libevdev.InputEvent(libevdev.EV_REL)  # type: ignore
15262306a36Sopenharmony_ci        msc_event = libevdev.InputEvent(libevdev.EV_MSC.MSC_SCAN)  # type: ignore
15362306a36Sopenharmony_ci
15462306a36Sopenharmony_ci        # List of kernel modules to load before starting the test
15562306a36Sopenharmony_ci        # if any module is not available (not compiled), the test will skip.
15662306a36Sopenharmony_ci        # Each element is a tuple '(kernel driver name, kernel module)',
15762306a36Sopenharmony_ci        # for example ("playstation", "hid-playstation")
15862306a36Sopenharmony_ci        kernel_modules = []
15962306a36Sopenharmony_ci
16062306a36Sopenharmony_ci        def assertInputEventsIn(self, expected_events, effective_events):
16162306a36Sopenharmony_ci            effective_events = effective_events.copy()
16262306a36Sopenharmony_ci            for ev in expected_events:
16362306a36Sopenharmony_ci                assert ev in effective_events
16462306a36Sopenharmony_ci                effective_events.remove(ev)
16562306a36Sopenharmony_ci            return effective_events
16662306a36Sopenharmony_ci
16762306a36Sopenharmony_ci        def assertInputEvents(self, expected_events, effective_events):
16862306a36Sopenharmony_ci            remaining = self.assertInputEventsIn(expected_events, effective_events)
16962306a36Sopenharmony_ci            assert remaining == []
17062306a36Sopenharmony_ci
17162306a36Sopenharmony_ci        @classmethod
17262306a36Sopenharmony_ci        def debug_reports(cls, reports, uhdev=None, events=None):
17362306a36Sopenharmony_ci            data = [" ".join([f"{v:02x}" for v in r]) for r in reports]
17462306a36Sopenharmony_ci
17562306a36Sopenharmony_ci            if uhdev is not None:
17662306a36Sopenharmony_ci                human_data = [
17762306a36Sopenharmony_ci                    uhdev.parsed_rdesc.format_report(r, split_lines=True)
17862306a36Sopenharmony_ci                    for r in reports
17962306a36Sopenharmony_ci                ]
18062306a36Sopenharmony_ci                try:
18162306a36Sopenharmony_ci                    human_data = [
18262306a36Sopenharmony_ci                        f'\n\t       {" " * h.index("/")}'.join(h.split("\n"))
18362306a36Sopenharmony_ci                        for h in human_data
18462306a36Sopenharmony_ci                    ]
18562306a36Sopenharmony_ci                except ValueError:
18662306a36Sopenharmony_ci                    # '/' not found: not a numbered report
18762306a36Sopenharmony_ci                    human_data = ["\n\t      ".join(h.split("\n")) for h in human_data]
18862306a36Sopenharmony_ci                data = [f"{d}\n\t ====> {h}" for d, h in zip(data, human_data)]
18962306a36Sopenharmony_ci
19062306a36Sopenharmony_ci            reports = data
19162306a36Sopenharmony_ci
19262306a36Sopenharmony_ci            if len(reports) == 1:
19362306a36Sopenharmony_ci                print("sending 1 report:")
19462306a36Sopenharmony_ci            else:
19562306a36Sopenharmony_ci                print(f"sending {len(reports)} reports:")
19662306a36Sopenharmony_ci            for report in reports:
19762306a36Sopenharmony_ci                print("\t", report)
19862306a36Sopenharmony_ci
19962306a36Sopenharmony_ci            if events is not None:
20062306a36Sopenharmony_ci                print("events received:", events)
20162306a36Sopenharmony_ci
20262306a36Sopenharmony_ci        def create_device(self):
20362306a36Sopenharmony_ci            raise Exception("please reimplement me in subclasses")
20462306a36Sopenharmony_ci
20562306a36Sopenharmony_ci        def _load_kernel_module(self, kernel_driver, kernel_module):
20662306a36Sopenharmony_ci            sysfs_path = Path("/sys/bus/hid/drivers")
20762306a36Sopenharmony_ci            if kernel_driver is not None:
20862306a36Sopenharmony_ci                sysfs_path /= kernel_driver
20962306a36Sopenharmony_ci            else:
21062306a36Sopenharmony_ci                # special case for when testing all available modules:
21162306a36Sopenharmony_ci                # we don't know beforehand the name of the module from modinfo
21262306a36Sopenharmony_ci                sysfs_path = Path("/sys/module") / kernel_module.replace("-", "_")
21362306a36Sopenharmony_ci            if not sysfs_path.exists():
21462306a36Sopenharmony_ci                import subprocess
21562306a36Sopenharmony_ci
21662306a36Sopenharmony_ci                ret = subprocess.run(["/usr/sbin/modprobe", kernel_module])
21762306a36Sopenharmony_ci                if ret.returncode != 0:
21862306a36Sopenharmony_ci                    pytest.skip(
21962306a36Sopenharmony_ci                        f"module {kernel_module} could not be loaded, skipping the test"
22062306a36Sopenharmony_ci                    )
22162306a36Sopenharmony_ci
22262306a36Sopenharmony_ci        @pytest.fixture()
22362306a36Sopenharmony_ci        def load_kernel_module(self):
22462306a36Sopenharmony_ci            for kernel_driver, kernel_module in self.kernel_modules:
22562306a36Sopenharmony_ci                self._load_kernel_module(kernel_driver, kernel_module)
22662306a36Sopenharmony_ci            yield
22762306a36Sopenharmony_ci
22862306a36Sopenharmony_ci        @pytest.fixture()
22962306a36Sopenharmony_ci        def new_uhdev(self, load_kernel_module):
23062306a36Sopenharmony_ci            return self.create_device()
23162306a36Sopenharmony_ci
23262306a36Sopenharmony_ci        def assertName(self, uhdev):
23362306a36Sopenharmony_ci            evdev = uhdev.get_evdev()
23462306a36Sopenharmony_ci            assert uhdev.name in evdev.name
23562306a36Sopenharmony_ci
23662306a36Sopenharmony_ci        @pytest.fixture(autouse=True)
23762306a36Sopenharmony_ci        def context(self, new_uhdev, request):
23862306a36Sopenharmony_ci            try:
23962306a36Sopenharmony_ci                with HIDTestUdevRule.instance():
24062306a36Sopenharmony_ci                    with new_uhdev as self.uhdev:
24162306a36Sopenharmony_ci                        skip_cond = request.node.get_closest_marker("skip_if_uhdev")
24262306a36Sopenharmony_ci                        if skip_cond:
24362306a36Sopenharmony_ci                            test, message, *rest = skip_cond.args
24462306a36Sopenharmony_ci
24562306a36Sopenharmony_ci                            if test(self.uhdev):
24662306a36Sopenharmony_ci                                pytest.skip(message)
24762306a36Sopenharmony_ci
24862306a36Sopenharmony_ci                        self.uhdev.create_kernel_device()
24962306a36Sopenharmony_ci                        now = time.time()
25062306a36Sopenharmony_ci                        while not self.uhdev.is_ready() and time.time() - now < 5:
25162306a36Sopenharmony_ci                            self.uhdev.dispatch(1)
25262306a36Sopenharmony_ci                        if self.uhdev.get_evdev() is None:
25362306a36Sopenharmony_ci                            logger.warning(
25462306a36Sopenharmony_ci                                f"available list of input nodes: (default application is '{self.uhdev.application}')"
25562306a36Sopenharmony_ci                            )
25662306a36Sopenharmony_ci                            logger.warning(self.uhdev.input_nodes)
25762306a36Sopenharmony_ci                        yield
25862306a36Sopenharmony_ci                        self.uhdev = None
25962306a36Sopenharmony_ci            except PermissionError:
26062306a36Sopenharmony_ci                pytest.skip("Insufficient permissions, run me as root")
26162306a36Sopenharmony_ci
26262306a36Sopenharmony_ci        @pytest.fixture(autouse=True)
26362306a36Sopenharmony_ci        def check_taint(self):
26462306a36Sopenharmony_ci            # we are abusing SysfsFile here, it's in /proc, but meh
26562306a36Sopenharmony_ci            taint_file = SysfsFile("/proc/sys/kernel/tainted")
26662306a36Sopenharmony_ci            taint = taint_file.int_value
26762306a36Sopenharmony_ci
26862306a36Sopenharmony_ci            yield
26962306a36Sopenharmony_ci
27062306a36Sopenharmony_ci            assert taint_file.int_value == taint
27162306a36Sopenharmony_ci
27262306a36Sopenharmony_ci        def test_creation(self):
27362306a36Sopenharmony_ci            """Make sure the device gets processed by the kernel and creates
27462306a36Sopenharmony_ci            the expected application input node.
27562306a36Sopenharmony_ci
27662306a36Sopenharmony_ci            If this fail, there is something wrong in the device report
27762306a36Sopenharmony_ci            descriptors."""
27862306a36Sopenharmony_ci            uhdev = self.uhdev
27962306a36Sopenharmony_ci            assert uhdev is not None
28062306a36Sopenharmony_ci            assert uhdev.get_evdev() is not None
28162306a36Sopenharmony_ci            self.assertName(uhdev)
28262306a36Sopenharmony_ci            assert len(uhdev.next_sync_events()) == 0
28362306a36Sopenharmony_ci            assert uhdev.get_evdev() is not None
28462306a36Sopenharmony_ci
28562306a36Sopenharmony_ci
28662306a36Sopenharmony_ciclass HIDTestUdevRule(object):
28762306a36Sopenharmony_ci    _instance = None
28862306a36Sopenharmony_ci    """
28962306a36Sopenharmony_ci    A context-manager compatible class that sets up our udev rules file and
29062306a36Sopenharmony_ci    deletes it on context exit.
29162306a36Sopenharmony_ci
29262306a36Sopenharmony_ci    This class is tailored to our test setup: it only sets up the udev rule
29362306a36Sopenharmony_ci    on the **second** context and it cleans it up again on the last context
29462306a36Sopenharmony_ci    removed. This matches the expected pytest setup: we enter a context for
29562306a36Sopenharmony_ci    the session once, then once for each test (the first of which will
29662306a36Sopenharmony_ci    trigger the udev rule) and once the last test exited and the session
29762306a36Sopenharmony_ci    exited, we clean up after ourselves.
29862306a36Sopenharmony_ci    """
29962306a36Sopenharmony_ci
30062306a36Sopenharmony_ci    def __init__(self):
30162306a36Sopenharmony_ci        self.refs = 0
30262306a36Sopenharmony_ci        self.rulesfile = None
30362306a36Sopenharmony_ci
30462306a36Sopenharmony_ci    def __enter__(self):
30562306a36Sopenharmony_ci        self.refs += 1
30662306a36Sopenharmony_ci        if self.refs == 2 and self.rulesfile is None:
30762306a36Sopenharmony_ci            self.create_udev_rule()
30862306a36Sopenharmony_ci            self.reload_udev_rules()
30962306a36Sopenharmony_ci
31062306a36Sopenharmony_ci    def __exit__(self, exc_type, exc_value, traceback):
31162306a36Sopenharmony_ci        self.refs -= 1
31262306a36Sopenharmony_ci        if self.refs == 0 and self.rulesfile:
31362306a36Sopenharmony_ci            os.remove(self.rulesfile.name)
31462306a36Sopenharmony_ci            self.reload_udev_rules()
31562306a36Sopenharmony_ci
31662306a36Sopenharmony_ci    def reload_udev_rules(self):
31762306a36Sopenharmony_ci        import subprocess
31862306a36Sopenharmony_ci
31962306a36Sopenharmony_ci        subprocess.run("udevadm control --reload-rules".split())
32062306a36Sopenharmony_ci        subprocess.run("systemd-hwdb update".split())
32162306a36Sopenharmony_ci
32262306a36Sopenharmony_ci    def create_udev_rule(self):
32362306a36Sopenharmony_ci        import tempfile
32462306a36Sopenharmony_ci
32562306a36Sopenharmony_ci        os.makedirs("/run/udev/rules.d", exist_ok=True)
32662306a36Sopenharmony_ci        with tempfile.NamedTemporaryFile(
32762306a36Sopenharmony_ci            prefix="91-uhid-test-device-REMOVEME-",
32862306a36Sopenharmony_ci            suffix=".rules",
32962306a36Sopenharmony_ci            mode="w+",
33062306a36Sopenharmony_ci            dir="/run/udev/rules.d",
33162306a36Sopenharmony_ci            delete=False,
33262306a36Sopenharmony_ci        ) as f:
33362306a36Sopenharmony_ci            f.write(
33462306a36Sopenharmony_ci                'KERNELS=="*input*", ATTRS{name}=="*uhid test *", ENV{LIBINPUT_IGNORE_DEVICE}="1"\n'
33562306a36Sopenharmony_ci            )
33662306a36Sopenharmony_ci            f.write(
33762306a36Sopenharmony_ci                'KERNELS=="*input*", ATTRS{name}=="*uhid test * System Multi Axis", ENV{ID_INPUT_TOUCHSCREEN}="", ENV{ID_INPUT_SYSTEM_MULTIAXIS}="1"\n'
33862306a36Sopenharmony_ci            )
33962306a36Sopenharmony_ci            self.rulesfile = f
34062306a36Sopenharmony_ci
34162306a36Sopenharmony_ci    @classmethod
34262306a36Sopenharmony_ci    def instance(cls):
34362306a36Sopenharmony_ci        if not cls._instance:
34462306a36Sopenharmony_ci            cls._instance = HIDTestUdevRule()
34562306a36Sopenharmony_ci        return cls._instance
346