346 lines
12 KiB
Python
346 lines
12 KiB
Python
#!/bin/env python3
|
|
# SPDX-License-Identifier: GPL-2.0
|
|
# -*- coding: utf-8 -*-
|
|
#
|
|
# Copyright (c) 2017 Benjamin Tissoires <benjamin.tissoires@gmail.com>
|
|
# Copyright (c) 2017 Red Hat, Inc.
|
|
|
|
import libevdev
|
|
import os
|
|
import pytest
|
|
import time
|
|
|
|
import logging
|
|
|
|
from hidtools.device.base_device import BaseDevice, EvdevMatch, SysfsFile
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
logger = logging.getLogger("hidtools.test.base")
|
|
|
|
# application to matches
|
|
application_matches: Final = {
|
|
# pyright: ignore
|
|
"Accelerometer": EvdevMatch(
|
|
req_properties=[
|
|
libevdev.INPUT_PROP_ACCELEROMETER,
|
|
]
|
|
),
|
|
"Game Pad": EvdevMatch( # in systemd, this is a lot more complex, but that will do
|
|
requires=[
|
|
libevdev.EV_ABS.ABS_X,
|
|
libevdev.EV_ABS.ABS_Y,
|
|
libevdev.EV_ABS.ABS_RX,
|
|
libevdev.EV_ABS.ABS_RY,
|
|
libevdev.EV_KEY.BTN_START,
|
|
],
|
|
excl_properties=[
|
|
libevdev.INPUT_PROP_ACCELEROMETER,
|
|
],
|
|
),
|
|
"Joystick": EvdevMatch( # in systemd, this is a lot more complex, but that will do
|
|
requires=[
|
|
libevdev.EV_ABS.ABS_RX,
|
|
libevdev.EV_ABS.ABS_RY,
|
|
libevdev.EV_KEY.BTN_START,
|
|
],
|
|
excl_properties=[
|
|
libevdev.INPUT_PROP_ACCELEROMETER,
|
|
],
|
|
),
|
|
"Key": EvdevMatch(
|
|
requires=[
|
|
libevdev.EV_KEY.KEY_A,
|
|
],
|
|
excl_properties=[
|
|
libevdev.INPUT_PROP_ACCELEROMETER,
|
|
libevdev.INPUT_PROP_DIRECT,
|
|
libevdev.INPUT_PROP_POINTER,
|
|
],
|
|
),
|
|
"Mouse": EvdevMatch(
|
|
requires=[
|
|
libevdev.EV_REL.REL_X,
|
|
libevdev.EV_REL.REL_Y,
|
|
libevdev.EV_KEY.BTN_LEFT,
|
|
],
|
|
excl_properties=[
|
|
libevdev.INPUT_PROP_ACCELEROMETER,
|
|
],
|
|
),
|
|
"Pad": EvdevMatch(
|
|
requires=[
|
|
libevdev.EV_KEY.BTN_0,
|
|
],
|
|
excludes=[
|
|
libevdev.EV_KEY.BTN_TOOL_PEN,
|
|
libevdev.EV_KEY.BTN_TOUCH,
|
|
libevdev.EV_ABS.ABS_DISTANCE,
|
|
],
|
|
excl_properties=[
|
|
libevdev.INPUT_PROP_ACCELEROMETER,
|
|
],
|
|
),
|
|
"Pen": EvdevMatch(
|
|
requires=[
|
|
libevdev.EV_KEY.BTN_STYLUS,
|
|
libevdev.EV_ABS.ABS_X,
|
|
libevdev.EV_ABS.ABS_Y,
|
|
],
|
|
excl_properties=[
|
|
libevdev.INPUT_PROP_ACCELEROMETER,
|
|
],
|
|
),
|
|
"Stylus": EvdevMatch(
|
|
requires=[
|
|
libevdev.EV_KEY.BTN_STYLUS,
|
|
libevdev.EV_ABS.ABS_X,
|
|
libevdev.EV_ABS.ABS_Y,
|
|
],
|
|
excl_properties=[
|
|
libevdev.INPUT_PROP_ACCELEROMETER,
|
|
],
|
|
),
|
|
"Touch Pad": EvdevMatch(
|
|
requires=[
|
|
libevdev.EV_KEY.BTN_LEFT,
|
|
libevdev.EV_ABS.ABS_X,
|
|
libevdev.EV_ABS.ABS_Y,
|
|
],
|
|
excludes=[libevdev.EV_KEY.BTN_TOOL_PEN, libevdev.EV_KEY.BTN_STYLUS],
|
|
req_properties=[
|
|
libevdev.INPUT_PROP_POINTER,
|
|
],
|
|
excl_properties=[
|
|
libevdev.INPUT_PROP_ACCELEROMETER,
|
|
],
|
|
),
|
|
"Touch Screen": EvdevMatch(
|
|
requires=[
|
|
libevdev.EV_KEY.BTN_TOUCH,
|
|
libevdev.EV_ABS.ABS_X,
|
|
libevdev.EV_ABS.ABS_Y,
|
|
],
|
|
excludes=[libevdev.EV_KEY.BTN_TOOL_PEN, libevdev.EV_KEY.BTN_STYLUS],
|
|
req_properties=[
|
|
libevdev.INPUT_PROP_DIRECT,
|
|
],
|
|
excl_properties=[
|
|
libevdev.INPUT_PROP_ACCELEROMETER,
|
|
],
|
|
),
|
|
}
|
|
|
|
|
|
class UHIDTestDevice(BaseDevice):
|
|
def __init__(self, name, application, rdesc_str=None, rdesc=None, input_info=None):
|
|
super().__init__(name, application, rdesc_str, rdesc, input_info)
|
|
self.application_matches = application_matches
|
|
if name is None:
|
|
name = f"uhid test {self.__class__.__name__}"
|
|
if not name.startswith("uhid test "):
|
|
name = "uhid test " + self.name
|
|
self.name = name
|
|
|
|
|
|
class BaseTestCase:
|
|
class TestUhid(object):
|
|
syn_event = libevdev.InputEvent(libevdev.EV_SYN.SYN_REPORT) # type: ignore
|
|
key_event = libevdev.InputEvent(libevdev.EV_KEY) # type: ignore
|
|
abs_event = libevdev.InputEvent(libevdev.EV_ABS) # type: ignore
|
|
rel_event = libevdev.InputEvent(libevdev.EV_REL) # type: ignore
|
|
msc_event = libevdev.InputEvent(libevdev.EV_MSC.MSC_SCAN) # type: ignore
|
|
|
|
# List of kernel modules to load before starting the test
|
|
# if any module is not available (not compiled), the test will skip.
|
|
# Each element is a tuple '(kernel driver name, kernel module)',
|
|
# for example ("playstation", "hid-playstation")
|
|
kernel_modules = []
|
|
|
|
def assertInputEventsIn(self, expected_events, effective_events):
|
|
effective_events = effective_events.copy()
|
|
for ev in expected_events:
|
|
assert ev in effective_events
|
|
effective_events.remove(ev)
|
|
return effective_events
|
|
|
|
def assertInputEvents(self, expected_events, effective_events):
|
|
remaining = self.assertInputEventsIn(expected_events, effective_events)
|
|
assert remaining == []
|
|
|
|
@classmethod
|
|
def debug_reports(cls, reports, uhdev=None, events=None):
|
|
data = [" ".join([f"{v:02x}" for v in r]) for r in reports]
|
|
|
|
if uhdev is not None:
|
|
human_data = [
|
|
uhdev.parsed_rdesc.format_report(r, split_lines=True)
|
|
for r in reports
|
|
]
|
|
try:
|
|
human_data = [
|
|
f'\n\t {" " * h.index("/")}'.join(h.split("\n"))
|
|
for h in human_data
|
|
]
|
|
except ValueError:
|
|
# '/' not found: not a numbered report
|
|
human_data = ["\n\t ".join(h.split("\n")) for h in human_data]
|
|
data = [f"{d}\n\t ====> {h}" for d, h in zip(data, human_data)]
|
|
|
|
reports = data
|
|
|
|
if len(reports) == 1:
|
|
print("sending 1 report:")
|
|
else:
|
|
print(f"sending {len(reports)} reports:")
|
|
for report in reports:
|
|
print("\t", report)
|
|
|
|
if events is not None:
|
|
print("events received:", events)
|
|
|
|
def create_device(self):
|
|
raise Exception("please reimplement me in subclasses")
|
|
|
|
def _load_kernel_module(self, kernel_driver, kernel_module):
|
|
sysfs_path = Path("/sys/bus/hid/drivers")
|
|
if kernel_driver is not None:
|
|
sysfs_path /= kernel_driver
|
|
else:
|
|
# special case for when testing all available modules:
|
|
# we don't know beforehand the name of the module from modinfo
|
|
sysfs_path = Path("/sys/module") / kernel_module.replace("-", "_")
|
|
if not sysfs_path.exists():
|
|
import subprocess
|
|
|
|
ret = subprocess.run(["/usr/sbin/modprobe", kernel_module])
|
|
if ret.returncode != 0:
|
|
pytest.skip(
|
|
f"module {kernel_module} could not be loaded, skipping the test"
|
|
)
|
|
|
|
@pytest.fixture()
|
|
def load_kernel_module(self):
|
|
for kernel_driver, kernel_module in self.kernel_modules:
|
|
self._load_kernel_module(kernel_driver, kernel_module)
|
|
yield
|
|
|
|
@pytest.fixture()
|
|
def new_uhdev(self, load_kernel_module):
|
|
return self.create_device()
|
|
|
|
def assertName(self, uhdev):
|
|
evdev = uhdev.get_evdev()
|
|
assert uhdev.name in evdev.name
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def context(self, new_uhdev, request):
|
|
try:
|
|
with HIDTestUdevRule.instance():
|
|
with new_uhdev as self.uhdev:
|
|
skip_cond = request.node.get_closest_marker("skip_if_uhdev")
|
|
if skip_cond:
|
|
test, message, *rest = skip_cond.args
|
|
|
|
if test(self.uhdev):
|
|
pytest.skip(message)
|
|
|
|
self.uhdev.create_kernel_device()
|
|
now = time.time()
|
|
while not self.uhdev.is_ready() and time.time() - now < 5:
|
|
self.uhdev.dispatch(1)
|
|
if self.uhdev.get_evdev() is None:
|
|
logger.warning(
|
|
f"available list of input nodes: (default application is '{self.uhdev.application}')"
|
|
)
|
|
logger.warning(self.uhdev.input_nodes)
|
|
yield
|
|
self.uhdev = None
|
|
except PermissionError:
|
|
pytest.skip("Insufficient permissions, run me as root")
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def check_taint(self):
|
|
# we are abusing SysfsFile here, it's in /proc, but meh
|
|
taint_file = SysfsFile("/proc/sys/kernel/tainted")
|
|
taint = taint_file.int_value
|
|
|
|
yield
|
|
|
|
assert taint_file.int_value == taint
|
|
|
|
def test_creation(self):
|
|
"""Make sure the device gets processed by the kernel and creates
|
|
the expected application input node.
|
|
|
|
If this fail, there is something wrong in the device report
|
|
descriptors."""
|
|
uhdev = self.uhdev
|
|
assert uhdev is not None
|
|
assert uhdev.get_evdev() is not None
|
|
self.assertName(uhdev)
|
|
assert len(uhdev.next_sync_events()) == 0
|
|
assert uhdev.get_evdev() is not None
|
|
|
|
|
|
class HIDTestUdevRule(object):
|
|
_instance = None
|
|
"""
|
|
A context-manager compatible class that sets up our udev rules file and
|
|
deletes it on context exit.
|
|
|
|
This class is tailored to our test setup: it only sets up the udev rule
|
|
on the **second** context and it cleans it up again on the last context
|
|
removed. This matches the expected pytest setup: we enter a context for
|
|
the session once, then once for each test (the first of which will
|
|
trigger the udev rule) and once the last test exited and the session
|
|
exited, we clean up after ourselves.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.refs = 0
|
|
self.rulesfile = None
|
|
|
|
def __enter__(self):
|
|
self.refs += 1
|
|
if self.refs == 2 and self.rulesfile is None:
|
|
self.create_udev_rule()
|
|
self.reload_udev_rules()
|
|
|
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
self.refs -= 1
|
|
if self.refs == 0 and self.rulesfile:
|
|
os.remove(self.rulesfile.name)
|
|
self.reload_udev_rules()
|
|
|
|
def reload_udev_rules(self):
|
|
import subprocess
|
|
|
|
subprocess.run("udevadm control --reload-rules".split())
|
|
subprocess.run("systemd-hwdb update".split())
|
|
|
|
def create_udev_rule(self):
|
|
import tempfile
|
|
|
|
os.makedirs("/run/udev/rules.d", exist_ok=True)
|
|
with tempfile.NamedTemporaryFile(
|
|
prefix="91-uhid-test-device-REMOVEME-",
|
|
suffix=".rules",
|
|
mode="w+",
|
|
dir="/run/udev/rules.d",
|
|
delete=False,
|
|
) as f:
|
|
f.write(
|
|
'KERNELS=="*input*", ATTRS{name}=="*uhid test *", ENV{LIBINPUT_IGNORE_DEVICE}="1"\n'
|
|
)
|
|
f.write(
|
|
'KERNELS=="*input*", ATTRS{name}=="*uhid test * System Multi Axis", ENV{ID_INPUT_TOUCHSCREEN}="", ENV{ID_INPUT_SYSTEM_MULTIAXIS}="1"\n'
|
|
)
|
|
self.rulesfile = f
|
|
|
|
@classmethod
|
|
def instance(cls):
|
|
if not cls._instance:
|
|
cls._instance = HIDTestUdevRule()
|
|
return cls._instance
|