diff options
Diffstat (limited to 'tools/testing/selftests/hid/tests/test_wacom_generic.py')
-rw-r--r-- | tools/testing/selftests/hid/tests/test_wacom_generic.py | 1198 |
1 files changed, 1198 insertions, 0 deletions
diff --git a/tools/testing/selftests/hid/tests/test_wacom_generic.py b/tools/testing/selftests/hid/tests/test_wacom_generic.py new file mode 100644 index 000000000000..b62c7dba6777 --- /dev/null +++ b/tools/testing/selftests/hid/tests/test_wacom_generic.py @@ -0,0 +1,1198 @@ +#!/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. +# Copyright (c) 2020 Wacom Technology Corp. +# +# Authors: +# Jason Gerecke <jason.gerecke@wacom.com> + +""" +Tests for the Wacom driver generic codepath. + +This module tests the function of the Wacom driver's generic codepath. +The generic codepath is used by devices which are not explicitly listed +in the driver's device table. It uses the device's HID descriptor to +decode reports sent by the device. +""" + +from .descriptors_wacom import ( + wacom_pth660_v145, + wacom_pth660_v150, + wacom_pth860_v145, + wacom_pth860_v150, + wacom_pth460_v105, +) + +import attr +from collections import namedtuple +from enum import Enum +from hidtools.hut import HUT +from hidtools.hid import HidUnit +from . import base +from . import test_multitouch +import libevdev +import pytest + +import logging + +logger = logging.getLogger("hidtools.test.wacom") + +KERNEL_MODULE = ("wacom", "wacom") + + +class ProximityState(Enum): + """ + Enumeration of allowed proximity states. + """ + + # Tool is not able to be sensed by the device + OUT = 0 + + # Tool is close enough to be sensed, but some data may be invalid + # or inaccurate + IN_PROXIMITY = 1 + + # Tool is close enough to be sensed with high accuracy. All data + # valid. + IN_RANGE = 2 + + def fill(self, reportdata): + """Fill a report with approrpiate HID properties/values.""" + reportdata.inrange = self in [ProximityState.IN_RANGE] + reportdata.wacomsense = self in [ + ProximityState.IN_PROXIMITY, + ProximityState.IN_RANGE, + ] + + +class ReportData: + """ + Placeholder for HID report values. + """ + + pass + + +@attr.s +class Buttons: + """ + Stylus button state. + + Describes the state of each of the buttons / "side switches" that + may be present on a stylus. Buttons set to 'None' indicate the + state is "unchanged" since the previous event. + """ + + primary = attr.ib(default=None) + secondary = attr.ib(default=None) + tertiary = attr.ib(default=None) + + @staticmethod + def clear(): + """Button object with all states cleared.""" + return Buttons(False, False, False) + + def fill(self, reportdata): + """Fill a report with approrpiate HID properties/values.""" + reportdata.barrelswitch = int(self.primary or 0) + reportdata.secondarybarrelswitch = int(self.secondary or 0) + reportdata.b3 = int(self.tertiary or 0) + + +@attr.s +class ToolID: + """ + Stylus tool identifiers. + + Contains values used to identify a specific stylus, e.g. its serial + number and tool-type identifier. Values of ``0`` may sometimes be + used for the out-of-range condition. + """ + + serial = attr.ib() + tooltype = attr.ib() + + @staticmethod + def clear(): + """ToolID object with all fields cleared.""" + return ToolID(0, 0) + + def fill(self, reportdata): + """Fill a report with approrpiate HID properties/values.""" + reportdata.transducerserialnumber = self.serial & 0xFFFFFFFF + reportdata.serialhi = (self.serial >> 32) & 0xFFFFFFFF + reportdata.tooltype = self.tooltype + + +@attr.s +class PhysRange: + """ + Range of HID physical values, with units. + """ + + unit = attr.ib() + min_size = attr.ib() + max_size = attr.ib() + + CENTIMETER = HidUnit.from_string("SILinear: cm") + DEGREE = HidUnit.from_string("EnglishRotation: deg") + + def contains(self, field): + """ + Check if the physical size of the provided field is in range. + + Compare the physical size described by the provided HID field + against the range of sizes described by this object. This is + an exclusive range comparison (e.g. 0 cm is not within the + range 0 cm - 5 cm) and exact unit comparison (e.g. 1 inch is + not within the range 0 cm - 5 cm). + """ + phys_size = (field.physical_max - field.physical_min) * 10 ** (field.unit_exp) + return ( + field.unit == self.unit.value + and phys_size > self.min_size + and phys_size < self.max_size + ) + + +class BaseTablet(base.UHIDTestDevice): + """ + Skeleton object for all kinds of tablet devices. + """ + + def __init__(self, rdesc, name=None, info=None): + assert rdesc is not None + super().__init__(name, "Pen", input_info=info, rdesc=rdesc) + self.buttons = Buttons.clear() + self.toolid = ToolID.clear() + self.proximity = ProximityState.OUT + self.offset = 0 + self.ring = -1 + self.ek0 = False + + def match_evdev_rule(self, application, evdev): + """ + Filter out evdev nodes based on the requested application. + + The Wacom driver may create several device nodes for each USB + interface device. It is crucial that we run tests with the + expected device node or things will obviously go off the rails. + Use the Wacom driver's usual naming conventions to apply a + sensible default filter. + """ + if application in ["Pen", "Pad"]: + return evdev.name.endswith(application) + else: + return True + + def create_report( + self, x, y, pressure, buttons=None, toolid=None, proximity=None, reportID=None + ): + """ + Return an input report for this device. + + :param x: absolute x + :param y: absolute y + :param pressure: pressure + :param buttons: stylus button state. Use ``None`` for unchanged. + :param toolid: tool identifiers. Use ``None`` for unchanged. + :param proximity: a ProximityState indicating the sensor's ability + to detect and report attributes of this tool. Use ``None`` + for unchanged. + :param reportID: the numeric report ID for this report, if needed + """ + if buttons is not None: + self.buttons = buttons + buttons = self.buttons + + if toolid is not None: + self.toolid = toolid + toolid = self.toolid + + if proximity is not None: + self.proximity = proximity + proximity = self.proximity + + reportID = reportID or self.default_reportID + + report = ReportData() + report.x = x + report.y = y + report.tippressure = pressure + report.tipswitch = pressure > 0 + buttons.fill(report) + proximity.fill(report) + toolid.fill(report) + + return super().create_report(report, reportID=reportID) + + def create_report_heartbeat(self, reportID): + """ + Return a heartbeat input report for this device. + + Heartbeat reports generally contain battery status information, + among other things. + """ + report = ReportData() + report.wacombatterycharging = 1 + return super().create_report(report, reportID=reportID) + + def create_report_pad(self, reportID, ring, ek0): + report = ReportData() + + if ring is not None: + self.ring = ring + ring = self.ring + + if ek0 is not None: + self.ek0 = ek0 + ek0 = self.ek0 + + if ring >= 0: + report.wacomtouchring = ring + report.wacomtouchringstatus = 1 + else: + report.wacomtouchring = 0x7F + report.wacomtouchringstatus = 0 + + report.wacomexpresskey00 = ek0 + return super().create_report(report, reportID=reportID) + + def event(self, x, y, pressure, buttons=None, toolid=None, proximity=None): + """ + Send an input event on the default report ID. + + :param x: absolute x + :param y: absolute y + :param buttons: stylus button state. Use ``None`` for unchanged. + :param toolid: tool identifiers. Use ``None`` for unchanged. + :param proximity: a ProximityState indicating the sensor's ability + to detect and report attributes of this tool. Use ``None`` + for unchanged. + """ + r = self.create_report(x, y, pressure, buttons, toolid, proximity) + self.call_input_event(r) + return [r] + + def event_heartbeat(self, reportID): + """ + Send a heartbeat event on the requested report ID. + """ + r = self.create_report_heartbeat(reportID) + self.call_input_event(r) + return [r] + + def event_pad(self, reportID, ring=None, ek0=None): + """ + Send a pad event on the requested report ID. + """ + r = self.create_report_pad(reportID, ring, ek0) + self.call_input_event(r) + return [r] + + def get_report(self, req, rnum, rtype): + if rtype != self.UHID_FEATURE_REPORT: + return (1, []) + + rdesc = None + for v in self.parsed_rdesc.feature_reports.values(): + if v.report_ID == rnum: + rdesc = v + + if rdesc is None: + return (1, []) + + result = (1, []) + result = self.create_report_offset(rdesc) or result + return result + + def create_report_offset(self, rdesc): + require = [ + "Wacom Offset Left", + "Wacom Offset Top", + "Wacom Offset Right", + "Wacom Offset Bottom", + ] + if not set(require).issubset(set([f.usage_name for f in rdesc])): + return None + + report = ReportData() + report.wacomoffsetleft = self.offset + report.wacomoffsettop = self.offset + report.wacomoffsetright = self.offset + report.wacomoffsetbottom = self.offset + r = rdesc.create_report([report], None) + return (0, r) + + +class OpaqueTablet(BaseTablet): + """ + Bare-bones opaque tablet with a minimum of features. + + A tablet stripped down to its absolute core. It is capable of + reporting X/Y position and if the pen is in contact. No pressure, + no barrel switches, no eraser. Notably it *does* report an "In + Range" flag, but this is only because the Wacom driver expects + one to function properly. The device uses only standard HID usages, + not any of Wacom's vendor-defined pages. + """ + + # fmt: off + report_descriptor = [ + 0x05, 0x0D, # . Usage Page (Digitizer), + 0x09, 0x01, # . Usage (Digitizer), + 0xA1, 0x01, # . Collection (Application), + 0x85, 0x01, # . Report ID (1), + 0x09, 0x20, # . Usage (Stylus), + 0xA1, 0x00, # . Collection (Physical), + 0x09, 0x42, # . Usage (Tip Switch), + 0x09, 0x32, # . Usage (In Range), + 0x15, 0x00, # . Logical Minimum (0), + 0x25, 0x01, # . Logical Maximum (1), + 0x75, 0x01, # . Report Size (1), + 0x95, 0x02, # . Report Count (2), + 0x81, 0x02, # . Input (Variable), + 0x95, 0x06, # . Report Count (6), + 0x81, 0x03, # . Input (Constant, Variable), + 0x05, 0x01, # . Usage Page (Desktop), + 0x09, 0x30, # . Usage (X), + 0x27, 0x80, 0x3E, 0x00, 0x00, # . Logical Maximum (16000), + 0x47, 0x80, 0x3E, 0x00, 0x00, # . Physical Maximum (16000), + 0x65, 0x11, # . Unit (Centimeter), + 0x55, 0x0D, # . Unit Exponent (13), + 0x75, 0x10, # . Report Size (16), + 0x95, 0x01, # . Report Count (1), + 0x81, 0x02, # . Input (Variable), + 0x09, 0x31, # . Usage (Y), + 0x27, 0x28, 0x23, 0x00, 0x00, # . Logical Maximum (9000), + 0x47, 0x28, 0x23, 0x00, 0x00, # . Physical Maximum (9000), + 0x81, 0x02, # . Input (Variable), + 0xC0, # . End Collection, + 0xC0, # . End Collection, + ] + # fmt: on + + def __init__(self, rdesc=report_descriptor, name=None, info=(0x3, 0x056A, 0x9999)): + super().__init__(rdesc, name, info) + self.default_reportID = 1 + + +class OpaqueCTLTablet(BaseTablet): + """ + Opaque tablet similar to something in the CTL product line. + + A pen-only tablet with most basic features you would expect from + an actual device. Position, eraser, pressure, barrel buttons. + Uses the Wacom vendor-defined usage page. + """ + + # fmt: off + report_descriptor = [ + 0x06, 0x0D, 0xFF, # . Usage Page (Vnd Wacom Emr), + 0x09, 0x01, # . Usage (Digitizer), + 0xA1, 0x01, # . Collection (Application), + 0x85, 0x10, # . Report ID (16), + 0x09, 0x20, # . Usage (Stylus), + 0x35, 0x00, # . Physical Minimum (0), + 0x45, 0x00, # . Physical Maximum (0), + 0x15, 0x00, # . Logical Minimum (0), + 0x25, 0x01, # . Logical Maximum (1), + 0xA1, 0x00, # . Collection (Physical), + 0x09, 0x42, # . Usage (Tip Switch), + 0x09, 0x44, # . Usage (Barrel Switch), + 0x09, 0x5A, # . Usage (Secondary Barrel Switch), + 0x09, 0x45, # . Usage (Eraser), + 0x09, 0x3C, # . Usage (Invert), + 0x09, 0x32, # . Usage (In Range), + 0x09, 0x36, # . Usage (In Proximity), + 0x25, 0x01, # . Logical Maximum (1), + 0x75, 0x01, # . Report Size (1), + 0x95, 0x07, # . Report Count (7), + 0x81, 0x02, # . Input (Variable), + 0x95, 0x01, # . Report Count (1), + 0x81, 0x03, # . Input (Constant, Variable), + 0x0A, 0x30, 0x01, # . Usage (X), + 0x65, 0x11, # . Unit (Centimeter), + 0x55, 0x0D, # . Unit Exponent (13), + 0x47, 0x80, 0x3E, 0x00, 0x00, # . Physical Maximum (16000), + 0x27, 0x80, 0x3E, 0x00, 0x00, # . Logical Maximum (16000), + 0x75, 0x18, # . Report Size (24), + 0x95, 0x01, # . Report Count (1), + 0x81, 0x02, # . Input (Variable), + 0x0A, 0x31, 0x01, # . Usage (Y), + 0x47, 0x28, 0x23, 0x00, 0x00, # . Physical Maximum (9000), + 0x27, 0x28, 0x23, 0x00, 0x00, # . Logical Maximum (9000), + 0x81, 0x02, # . Input (Variable), + 0x09, 0x30, # . Usage (Tip Pressure), + 0x55, 0x00, # . Unit Exponent (0), + 0x65, 0x00, # . Unit, + 0x47, 0x00, 0x00, 0x00, 0x00, # . Physical Maximum (0), + 0x26, 0xFF, 0x0F, # . Logical Maximum (4095), + 0x75, 0x10, # . Report Size (16), + 0x81, 0x02, # . Input (Variable), + 0x75, 0x08, # . Report Size (8), + 0x95, 0x06, # . Report Count (6), + 0x81, 0x03, # . Input (Constant, Variable), + 0x0A, 0x32, 0x01, # . Usage (Z), + 0x25, 0x3F, # . Logical Maximum (63), + 0x75, 0x08, # . Report Size (8), + 0x95, 0x01, # . Report Count (1), + 0x81, 0x02, # . Input (Variable), + 0x09, 0x5B, # . Usage (Transducer Serial Number), + 0x09, 0x5C, # . Usage (Transducer Serial Number Hi), + 0x17, 0x00, 0x00, 0x00, 0x80, # . Logical Minimum (-2147483648), + 0x27, 0xFF, 0xFF, 0xFF, 0x7F, # . Logical Maximum (2147483647), + 0x75, 0x20, # . Report Size (32), + 0x95, 0x02, # . Report Count (2), + 0x81, 0x02, # . Input (Variable), + 0x09, 0x77, # . Usage (Tool Type), + 0x15, 0x00, # . Logical Minimum (0), + 0x26, 0xFF, 0x0F, # . Logical Maximum (4095), + 0x75, 0x10, # . Report Size (16), + 0x95, 0x01, # . Report Count (1), + 0x81, 0x02, # . Input (Variable), + 0xC0, # . End Collection, + 0xC0 # . End Collection + ] + # fmt: on + + def __init__(self, rdesc=report_descriptor, name=None, info=(0x3, 0x056A, 0x9999)): + super().__init__(rdesc, name, info) + self.default_reportID = 16 + + +class PTHX60_Pen(BaseTablet): + """ + Pen interface of a PTH-660 / PTH-860 / PTH-460 tablet. + + This generation of devices are nearly identical to each other, though + the PTH-460 uses a slightly different descriptor construction (splits + the pad among several physical collections) + """ + + def __init__(self, rdesc=None, name=None, info=None): + super().__init__(rdesc, name, info) + self.default_reportID = 16 + + +class BaseTest: + class TestTablet(base.BaseTestCase.TestUhid): + kernel_modules = [KERNEL_MODULE] + + def sync_and_assert_events( + self, report, expected_events, auto_syn=True, strict=False + ): + """ + Assert we see the expected events in response to a report. + """ + uhdev = self.uhdev + syn_event = self.syn_event + if auto_syn: + expected_events.append(syn_event) + actual_events = uhdev.next_sync_events() + self.debug_reports(report, uhdev, actual_events) + if strict: + self.assertInputEvents(expected_events, actual_events) + else: + self.assertInputEventsIn(expected_events, actual_events) + + def get_usages(self, uhdev): + def get_report_usages(report): + application = report.application + for field in report.fields: + if field.usages is not None: + for usage in field.usages: + yield (field, usage, application) + else: + yield (field, field.usage, application) + + desc = uhdev.parsed_rdesc + reports = [ + *desc.input_reports.values(), + *desc.feature_reports.values(), + *desc.output_reports.values(), + ] + for report in reports: + for usage in get_report_usages(report): + yield usage + + def assertName(self, uhdev, type): + """ + Assert that the name is as we expect. + + The Wacom driver applies a number of decorations to the name + provided by the hardware. We cannot rely on the definition of + this assertion from the base class to work properly. + """ + evdev = uhdev.get_evdev() + expected_name = uhdev.name + type + if "wacom" not in expected_name.lower(): + expected_name = "Wacom " + expected_name + assert evdev.name == expected_name + + def test_descriptor_physicals(self): + """ + Verify that all HID usages which should have a physical range + actually do, and those which shouldn't don't. Also verify that + the associated unit is correct and within a sensible range. + """ + + def usage_id(page_name, usage_name): + page = HUT.usage_page_from_name(page_name) + return (page.page_id << 16) | page[usage_name].usage + + required = { + usage_id("Generic Desktop", "X"): PhysRange( + PhysRange.CENTIMETER, 5, 150 + ), + usage_id("Generic Desktop", "Y"): PhysRange( + PhysRange.CENTIMETER, 5, 150 + ), + usage_id("Digitizers", "Width"): PhysRange( + PhysRange.CENTIMETER, 5, 150 + ), + usage_id("Digitizers", "Height"): PhysRange( + PhysRange.CENTIMETER, 5, 150 + ), + usage_id("Digitizers", "X Tilt"): PhysRange(PhysRange.DEGREE, 90, 180), + usage_id("Digitizers", "Y Tilt"): PhysRange(PhysRange.DEGREE, 90, 180), + usage_id("Digitizers", "Twist"): PhysRange(PhysRange.DEGREE, 358, 360), + usage_id("Wacom", "X Tilt"): PhysRange(PhysRange.DEGREE, 90, 180), + usage_id("Wacom", "Y Tilt"): PhysRange(PhysRange.DEGREE, 90, 180), + usage_id("Wacom", "Twist"): PhysRange(PhysRange.DEGREE, 358, 360), + usage_id("Wacom", "X"): PhysRange(PhysRange.CENTIMETER, 5, 150), + usage_id("Wacom", "Y"): PhysRange(PhysRange.CENTIMETER, 5, 150), + usage_id("Wacom", "Wacom TouchRing"): PhysRange( + PhysRange.DEGREE, 358, 360 + ), + usage_id("Wacom", "Wacom Offset Left"): PhysRange( + PhysRange.CENTIMETER, 0, 0.5 + ), + usage_id("Wacom", "Wacom Offset Top"): PhysRange( + PhysRange.CENTIMETER, 0, 0.5 + ), + usage_id("Wacom", "Wacom Offset Right"): PhysRange( + PhysRange.CENTIMETER, 0, 0.5 + ), + usage_id("Wacom", "Wacom Offset Bottom"): PhysRange( + PhysRange.CENTIMETER, 0, 0.5 + ), + } + for field, usage, application in self.get_usages(self.uhdev): + if application == usage_id("Generic Desktop", "Mouse"): + # Ignore the vestigial Mouse collection which exists + # on Wacom tablets only for backwards compatibility. + continue + + expect_physical = usage in required + + phys_set = field.physical_min != 0 or field.physical_max != 0 + assert phys_set == expect_physical + + unit_set = field.unit != 0 + assert unit_set == expect_physical + + if unit_set: + assert required[usage].contains(field) + + def test_prop_direct(self): + """ + Todo: Verify that INPUT_PROP_DIRECT is set on display devices. + """ + pass + + def test_prop_pointer(self): + """ + Todo: Verify that INPUT_PROP_POINTER is set on opaque devices. + """ + pass + + +class PenTabletTest(BaseTest.TestTablet): + def assertName(self, uhdev): + super().assertName(uhdev, " Pen") + + +class TouchTabletTest(BaseTest.TestTablet): + def assertName(self, uhdev): + super().assertName(uhdev, " Finger") + + +class TestOpaqueTablet(PenTabletTest): + def create_device(self): + return OpaqueTablet() + + def test_sanity(self): + """ + Bring a pen into contact with the tablet, then remove it. + + Ensure that we get the basic tool/touch/motion events that should + be sent by the driver. + """ + uhdev = self.uhdev + + self.sync_and_assert_events( + uhdev.event( + 100, + 200, + pressure=300, + buttons=Buttons.clear(), + toolid=ToolID(serial=1, tooltype=1), + proximity=ProximityState.IN_RANGE, + ), + [ + libevdev.InputEvent(libevdev.EV_KEY.BTN_TOOL_PEN, 1), + libevdev.InputEvent(libevdev.EV_ABS.ABS_X, 100), + libevdev.InputEvent(libevdev.EV_ABS.ABS_Y, 200), + libevdev.InputEvent(libevdev.EV_KEY.BTN_TOUCH, 1), + ], + ) + + self.sync_and_assert_events( + uhdev.event(110, 220, pressure=0), + [ + libevdev.InputEvent(libevdev.EV_ABS.ABS_X, 110), + libevdev.InputEvent(libevdev.EV_ABS.ABS_Y, 220), + libevdev.InputEvent(libevdev.EV_KEY.BTN_TOUCH, 0), + ], + ) + + self.sync_and_assert_events( + uhdev.event( + 120, + 230, + pressure=0, + toolid=ToolID.clear(), + proximity=ProximityState.OUT, + ), + [ + libevdev.InputEvent(libevdev.EV_KEY.BTN_TOOL_PEN, 0), + ], + ) + + self.sync_and_assert_events( + uhdev.event(130, 240, pressure=0), [], auto_syn=False, strict=True + ) + + +class TestOpaqueCTLTablet(TestOpaqueTablet): + def create_device(self): + return OpaqueCTLTablet() + + def test_buttons(self): + """ + Test that the barrel buttons (side switches) work as expected. + + Press and release each button individually to verify that we get + the expected events. + """ + uhdev = self.uhdev + + self.sync_and_assert_events( + uhdev.event( + 100, + 200, + pressure=0, + buttons=Buttons.clear(), + toolid=ToolID(serial=1, tooltype=1), + proximity=ProximityState.IN_RANGE, + ), + [ + libevdev.InputEvent(libevdev.EV_KEY.BTN_TOOL_PEN, 1), + libevdev.InputEvent(libevdev.EV_ABS.ABS_X, 100), + libevdev.InputEvent(libevdev.EV_ABS.ABS_Y, 200), + libevdev.InputEvent(libevdev.EV_MSC.MSC_SERIAL, 1), + ], + ) + + self.sync_and_assert_events( + uhdev.event(100, 200, pressure=0, buttons=Buttons(primary=True)), + [ + libevdev.InputEvent(libevdev.EV_KEY.BTN_STYLUS, 1), + libevdev.InputEvent(libevdev.EV_MSC.MSC_SERIAL, 1), + ], + ) + + self.sync_and_assert_events( + uhdev.event(100, 200, pressure=0, buttons=Buttons(primary=False)), + [ + libevdev.InputEvent(libevdev.EV_KEY.BTN_STYLUS, 0), + libevdev.InputEvent(libevdev.EV_MSC.MSC_SERIAL, 1), + ], + ) + + self.sync_and_assert_events( + uhdev.event(100, 200, pressure=0, buttons=Buttons(secondary=True)), + [ + libevdev.InputEvent(libevdev.EV_KEY.BTN_STYLUS2, 1), + libevdev.InputEvent(libevdev.EV_MSC.MSC_SERIAL, 1), + ], + ) + + self.sync_and_assert_events( + uhdev.event(100, 200, pressure=0, buttons=Buttons(secondary=False)), + [ + libevdev.InputEvent(libevdev.EV_KEY.BTN_STYLUS2, 0), + libevdev.InputEvent(libevdev.EV_MSC.MSC_SERIAL, 1), + ], + ) + + +PTHX60_Devices = [ + {"rdesc": wacom_pth660_v145, "info": (0x3, 0x056A, 0x0357)}, + {"rdesc": wacom_pth660_v150, "info": (0x3, 0x056A, 0x0357)}, + {"rdesc": wacom_pth860_v145, "info": (0x3, 0x056A, 0x0358)}, + {"rdesc": wacom_pth860_v150, "info": (0x3, 0x056A, 0x0358)}, + {"rdesc": wacom_pth460_v105, "info": (0x3, 0x056A, 0x0392)}, +] + +PTHX60_Names = [ + "PTH-660/v145", + "PTH-660/v150", + "PTH-860/v145", + "PTH-860/v150", + "PTH-460/v105", +] + + +class TestPTHX60_Pen(TestOpaqueCTLTablet): + @pytest.fixture( + autouse=True, scope="class", params=PTHX60_Devices, ids=PTHX60_Names + ) + def set_device_params(self, request): + request.cls.device_params = request.param + + def create_device(self): + return PTHX60_Pen(**self.device_params) + + @pytest.mark.xfail + def test_descriptor_physicals(self): + # XFAIL: Various documented errata + super().test_descriptor_physicals() + + def test_heartbeat_spurious(self): + """ + Test that the heartbeat report does not send spurious events. + """ + uhdev = self.uhdev + + self.sync_and_assert_events( + uhdev.event( + 100, + 200, + pressure=300, + buttons=Buttons.clear(), + toolid=ToolID(serial=1, tooltype=0x822), + proximity=ProximityState.IN_RANGE, + ), + [ + libevdev.InputEvent(libevdev.EV_KEY.BTN_TOOL_PEN, 1), + libevdev.InputEvent(libevdev.EV_ABS.ABS_X, 100), + libevdev.InputEvent(libevdev.EV_ABS.ABS_Y, 200), + libevdev.InputEvent(libevdev.EV_KEY.BTN_TOUCH, 1), + ], + ) + + # Exactly zero events: not even a SYN + self.sync_and_assert_events( + uhdev.event_heartbeat(19), [], auto_syn=False, strict=True + ) + + self.sync_and_assert_events( + uhdev.event(110, 200, pressure=300), + [ + libevdev.InputEvent(libevdev.EV_ABS.ABS_X, 110), + ], + ) + + def test_empty_pad_sync(self): + self.empty_pad_sync(num=3, denom=16, reverse=True) + + def empty_pad_sync(self, num, denom, reverse): + """ + Test that multiple pad collections do not trigger empty syncs. + """ + + def offset_rotation(value): + """ + Offset touchring rotation values by the same factor as the + Linux kernel. Tablets historically don't use the same origin + as HID, and it sometimes changes from tablet to tablet... + """ + evdev = self.uhdev.get_evdev() + info = evdev.absinfo[libevdev.EV_ABS.ABS_WHEEL] + delta = info.maximum - info.minimum + 1 + if reverse: + value = info.maximum - value + value += num * delta // denom + if value > info.maximum: + value -= delta + elif value < info.minimum: + value += delta + return value + + uhdev = self.uhdev + uhdev.application = "Pad" + evdev = uhdev.get_evdev() + + print(evdev.name) + self.sync_and_assert_events( + uhdev.event_pad(reportID=17, ring=0, ek0=1), + [ + libevdev.InputEvent(libevdev.EV_KEY.BTN_0, 1), + libevdev.InputEvent(libevdev.EV_ABS.ABS_WHEEL, offset_rotation(0)), + libevdev.InputEvent(libevdev.EV_ABS.ABS_MISC, 15), + ], + ) + + self.sync_and_assert_events( + uhdev.event_pad(reportID=17, ring=1, ek0=1), + [libevdev.InputEvent(libevdev.EV_ABS.ABS_WHEEL, offset_rotation(1))], + ) + + self.sync_and_assert_events( + uhdev.event_pad(reportID=17, ring=2, ek0=0), + [ + libevdev.InputEvent(libevdev.EV_ABS.ABS_WHEEL, offset_rotation(2)), + libevdev.InputEvent(libevdev.EV_KEY.BTN_0, 0), + ], + ) + + +class TestDTH2452Tablet(test_multitouch.BaseTest.TestMultitouch, TouchTabletTest): + ContactIds = namedtuple("ContactIds", "contact_id, tracking_id, slot_num") + + def create_device(self): + return test_multitouch.Digitizer( + "DTH 2452", + rdesc="05 0d 09 04 a1 01 85 0c 95 01 75 08 15 00 26 ff 00 81 03 09 54 81 02 09 22 a1 02 05 0d 95 01 75 01 25 01 09 42 81 02 81 03 09 47 81 02 95 05 81 03 09 51 26 ff 00 75 10 95 01 81 02 35 00 65 11 55 0e 05 01 09 30 26 a0 44 46 96 14 81 42 09 31 26 9a 26 46 95 0b 81 42 05 0d 75 08 95 01 15 00 09 48 26 5f 00 46 7c 14 81 02 09 49 25 35 46 7d 0b 81 02 45 00 65 00 55 00 c0 05 0d 09 22 a1 02 05 0d 95 01 75 01 25 01 09 42 81 02 81 03 09 47 81 02 95 05 81 03 09 51 26 ff 00 75 10 95 01 81 02 35 00 65 11 55 0e 05 01 09 30 26 a0 44 46 96 14 81 42 09 31 26 9a 26 46 95 0b 81 42 05 0d 75 08 95 01 15 00 09 48 26 5f 00 46 7c 14 81 02 09 49 25 35 46 7d 0b 81 02 45 00 65 00 55 00 c0 05 0d 09 22 a1 02 05 0d 95 01 75 01 25 01 09 42 81 02 81 03 09 47 81 02 95 05 81 03 09 51 26 ff 00 75 10 95 01 81 02 35 00 65 11 55 0e 05 01 09 30 26 a0 44 46 96 14 81 42 09 31 26 9a 26 46 95 0b 81 42 05 0d 75 08 95 01 15 00 09 48 26 5f 00 46 7c 14 81 02 09 49 25 35 46 7d 0b 81 02 45 00 65 00 55 00 c0 05 0d 09 22 a1 02 05 0d 95 01 75 01 25 01 09 42 81 02 81 03 09 47 81 02 95 05 81 03 09 51 26 ff 00 75 10 95 01 81 02 35 00 65 11 55 0e 05 01 09 30 26 a0 44 46 96 14 81 42 09 31 26 9a 26 46 95 0b 81 42 05 0d 75 08 95 01 15 00 09 48 26 5f 00 46 7c 14 81 02 09 49 25 35 46 7d 0b 81 02 45 00 65 00 55 00 c0 05 0d 09 22 a1 02 05 0d 95 01 75 01 25 01 09 42 81 02 81 03 09 47 81 02 95 05 81 03 09 51 26 ff 00 75 10 95 01 81 02 35 00 65 11 55 0e 05 01 09 30 26 a0 44 46 96 14 81 42 09 31 26 9a 26 46 95 0b 81 42 05 0d 75 08 95 01 15 00 09 48 26 5f 00 46 7c 14 81 02 09 49 25 35 46 7d 0b 81 02 45 00 65 00 55 00 c0 05 0d 27 ff ff 00 00 75 10 95 01 09 56 81 02 75 08 95 0e 81 03 09 55 26 ff 00 75 08 b1 02 85 0a 06 00 ff 09 c5 96 00 01 b1 02 c0 06 00 ff 09 01 a1 01 09 01 85 13 15 00 26 ff 00 75 08 95 3f 81 02 06 00 ff 09 01 15 00 26 ff 00 75 08 95 3f 91 02 c0", + input_info=(0x3, 0x056A, 0x0383), + ) + + def make_contact(self, contact_id=0, t=0): + """ + Make a single touch contact that can move over time. + + Creates a touch object that has a well-known position in space that + does not overlap with other contacts. The value of `t` may be + incremented over time to move the point along a linear path. + """ + x = 50 + 10 * contact_id + t * 11 + y = 100 + 100 * contact_id + t * 11 + return test_multitouch.Touch(contact_id, x, y) + + def make_contacts(self, n, t=0): + """ + Make multiple touch contacts that can move over time. + + Returns a list of `n` touch objects that are positioned at well-known + locations. The value of `t` may be incremented over time to move the + points along a linear path. + """ + return [ self.make_contact(id, t) for id in range(0, n) ] + + def assert_contact(self, uhdev, evdev, contact_ids, t=0): + """ + Assert properties of a contact generated by make_contact. + """ + contact_id = contact_ids.contact_id + tracking_id = contact_ids.tracking_id + slot_num = contact_ids.slot_num + + x = 50 + 10 * contact_id + t * 11 + y = 100 + 100 * contact_id + t * 11 + + # If the data isn't supposed to be stored in any slots, there is + # nothing we can check for in the evdev stream. + if slot_num is None: + assert tracking_id == -1 + return + + assert evdev.slots[slot_num][libevdev.EV_ABS.ABS_MT_TRACKING_ID] == tracking_id + if tracking_id != -1: + assert evdev.slots[slot_num][libevdev.EV_ABS.ABS_MT_POSITION_X] == x + assert evdev.slots[slot_num][libevdev.EV_ABS.ABS_MT_POSITION_Y] == y + + def assert_contacts(self, uhdev, evdev, data, t=0): + """ + Assert properties of a list of contacts generated by make_contacts. + """ + for contact_ids in data: + self.assert_contact(uhdev, evdev, contact_ids, t) + + def test_contact_id_0(self): + """ + Bring a finger in contact with the tablet, then hold it down and remove it. + + Ensure that even with contact ID = 0 which is usually given as an invalid + touch event by most tablets with the exception of a few, that given the + confidence bit is set to 1 it should process it as a valid touch to cover + the few tablets using contact ID = 0 as a valid touch value. + """ + uhdev = self.uhdev + evdev = uhdev.get_evdev() + + t0 = test_multitouch.Touch(0, 50, 100) + r = uhdev.event([t0]) + events = uhdev.next_sync_events() + self.debug_reports(r, uhdev, events) + + slot = self.get_slot(uhdev, t0, 0) + + assert libevdev.InputEvent(libevdev.EV_KEY.BTN_TOUCH, 1) in events + assert evdev.slots[slot][libevdev.EV_ABS.ABS_MT_TRACKING_ID] == 0 + assert evdev.slots[slot][libevdev.EV_ABS.ABS_MT_POSITION_X] == 50 + assert evdev.slots[slot][libevdev.EV_ABS.ABS_MT_POSITION_Y] == 100 + + t0.tipswitch = False + if uhdev.quirks is None or "VALID_IS_INRANGE" not in uhdev.quirks: + t0.inrange = False + r = uhdev.event([t0]) + events = uhdev.next_sync_events() + self.debug_reports(r, uhdev, events) + assert libevdev.InputEvent(libevdev.EV_KEY.BTN_TOUCH, 0) in events + assert evdev.slots[slot][libevdev.EV_ABS.ABS_MT_TRACKING_ID] == -1 + + def test_confidence_false(self): + """ + Bring a finger in contact with the tablet with confidence set to false. + + Ensure that the confidence bit being set to false should not result in a touch event. + """ + uhdev = self.uhdev + _evdev = uhdev.get_evdev() + + t0 = test_multitouch.Touch(1, 50, 100) + t0.confidence = False + r = uhdev.event([t0]) + events = uhdev.next_sync_events() + self.debug_reports(r, uhdev, events) + + _slot = self.get_slot(uhdev, t0, 0) + + assert not events + + def test_confidence_multitouch(self): + """ + Bring multiple fingers in contact with the tablet, some with the + confidence bit set, and some without. + + Ensure that all confident touches are reported and that all non- + confident touches are ignored. + """ + uhdev = self.uhdev + evdev = uhdev.get_evdev() + + touches = self.make_contacts(5) + touches[0].confidence = False + touches[2].confidence = False + touches[4].confidence = False + + r = uhdev.event(touches) + events = uhdev.next_sync_events() + self.debug_reports(r, uhdev, events) + + assert libevdev.InputEvent(libevdev.EV_KEY.BTN_TOUCH, 1) in events + + self.assert_contacts(uhdev, evdev, + [ self.ContactIds(contact_id = 0, tracking_id = -1, slot_num = None), + self.ContactIds(contact_id = 1, tracking_id = 0, slot_num = 0), + self.ContactIds(contact_id = 2, tracking_id = -1, slot_num = None), + self.ContactIds(contact_id = 3, tracking_id = 1, slot_num = 1), + self.ContactIds(contact_id = 4, tracking_id = -1, slot_num = None) ]) + + def confidence_change_assert_playback(self, uhdev, evdev, timeline): + """ + Assert proper behavior of contacts that move and change tipswitch / + confidence status over time. + + Given a `timeline` list of touch states to iterate over, verify + that the contacts move and are reported as up/down as expected + by the state of the tipswitch and confidence bits. + """ + t = 0 + + for state in timeline: + touches = self.make_contacts(len(state), t) + + for item in zip(touches, state): + item[0].tipswitch = item[1][1] + item[0].confidence = item[1][2] + + r = uhdev.event(touches) + events = uhdev.next_sync_events() + self.debug_reports(r, uhdev, events) + + ids = [ x[0] for x in state ] + self.assert_contacts(uhdev, evdev, ids, t) + + t += 1 + + def test_confidence_loss_a(self): + """ + Transition a confident contact to a non-confident contact by + first clearing the tipswitch. + + Ensure that the driver reports the transitioned contact as + being removed and that other contacts continue to report + normally. This mode of confidence loss is used by the + DTH-2452. + """ + uhdev = self.uhdev + evdev = uhdev.get_evdev() + + self.confidence_change_assert_playback(uhdev, evdev, [ + # t=0: Contact 0 == Down + confident; Contact 1 == Down + confident + # Both fingers confidently in contact + [(self.ContactIds(contact_id = 0, tracking_id = 0, slot_num = 0), True, True), + (self.ContactIds(contact_id = 1, tracking_id = 1, slot_num = 1), True, True)], + + # t=1: Contact 0 == !Down + confident; Contact 1 == Down + confident + # First finger looses confidence and clears only the tipswitch flag + [(self.ContactIds(contact_id = 0, tracking_id = -1, slot_num = 0), False, True), + (self.ContactIds(contact_id = 1, tracking_id = 1, slot_num = 1), True, True)], + + # t=2: Contact 0 == !Down + !confident; Contact 1 == Down + confident + # First finger has lost confidence and has both flags cleared + [(self.ContactIds(contact_id = 0, tracking_id = -1, slot_num = 0), False, False), + (self.ContactIds(contact_id = 1, tracking_id = 1, slot_num = 1), True, True)], + + # t=3: Contact 0 == !Down + !confident; Contact 1 == Down + confident + # First finger has lost confidence and has both flags cleared + [(self.ContactIds(contact_id = 0, tracking_id = -1, slot_num = 0), False, False), + (self.ContactIds(contact_id = 1, tracking_id = 1, slot_num = 1), True, True)] + ]) + + def test_confidence_loss_b(self): + """ + Transition a confident contact to a non-confident contact by + cleraing both tipswitch and confidence bits simultaneously. + + Ensure that the driver reports the transitioned contact as + being removed and that other contacts continue to report + normally. This mode of confidence loss is used by some + AES devices. + """ + uhdev = self.uhdev + evdev = uhdev.get_evdev() + + self.confidence_change_assert_playback(uhdev, evdev, [ + # t=0: Contact 0 == Down + confident; Contact 1 == Down + confident + # Both fingers confidently in contact + [(self.ContactIds(contact_id = 0, tracking_id = 0, slot_num = 0), True, True), + (self.ContactIds(contact_id = 1, tracking_id = 1, slot_num = 1), True, True)], + + # t=1: Contact 0 == !Down + !confident; Contact 1 == Down + confident + # First finger looses confidence and has both flags cleared simultaneously + [(self.ContactIds(contact_id = 0, tracking_id = -1, slot_num = 0), False, False), + (self.ContactIds(contact_id = 1, tracking_id = 1, slot_num = 1), True, True)], + + # t=2: Contact 0 == !Down + !confident; Contact 1 == Down + confident + # First finger has lost confidence and has both flags cleared + [(self.ContactIds(contact_id = 0, tracking_id = -1, slot_num = 0), False, False), + (self.ContactIds(contact_id = 1, tracking_id = 1, slot_num = 1), True, True)], + + # t=3: Contact 0 == !Down + !confident; Contact 1 == Down + confident + # First finger has lost confidence and has both flags cleared + [(self.ContactIds(contact_id = 0, tracking_id = -1, slot_num = 0), False, False), + (self.ContactIds(contact_id = 1, tracking_id = 1, slot_num = 1), True, True)] + ]) + + def test_confidence_loss_c(self): + """ + Transition a confident contact to a non-confident contact by + clearing only the confidence bit. + + Ensure that the driver reports the transitioned contact as + being removed and that other contacts continue to report + normally. + """ + uhdev = self.uhdev + evdev = uhdev.get_evdev() + + self.confidence_change_assert_playback(uhdev, evdev, [ + # t=0: Contact 0 == Down + confident; Contact 1 == Down + confident + # Both fingers confidently in contact + [(self.ContactIds(contact_id = 0, tracking_id = 0, slot_num = 0), True, True), + (self.ContactIds(contact_id = 1, tracking_id = 1, slot_num = 1), True, True)], + + # t=1: Contact 0 == Down + !confident; Contact 1 == Down + confident + # First finger looses confidence and clears only the confidence flag + [(self.ContactIds(contact_id = 0, tracking_id = -1, slot_num = 0), True, False), + (self.ContactIds(contact_id = 1, tracking_id = 1, slot_num = 1), True, True)], + + # t=2: Contact 0 == !Down + !confident; Contact 1 == Down + confident + # First finger has lost confidence and has both flags cleared + [(self.ContactIds(contact_id = 0, tracking_id = -1, slot_num = 0), False, False), + (self.ContactIds(contact_id = 1, tracking_id = 1, slot_num = 1), True, True)], + + # t=3: Contact 0 == !Down + !confident; Contact 1 == Down + confident + # First finger has lost confidence and has both flags cleared + [(self.ContactIds(contact_id = 0, tracking_id = -1, slot_num = 0), False, False), + (self.ContactIds(contact_id = 1, tracking_id = 1, slot_num = 1), True, True)] + ]) + + def test_confidence_gain_a(self): + """ + Transition a contact that was always non-confident to confident. + + Ensure that the confident contact is reported normally. + """ + uhdev = self.uhdev + evdev = uhdev.get_evdev() + + self.confidence_change_assert_playback(uhdev, evdev, [ + # t=0: Contact 0 == Down + !confident; Contact 1 == Down + confident + # Only second finger is confidently in contact + [(self.ContactIds(contact_id = 0, tracking_id = -1, slot_num = None), True, False), + (self.ContactIds(contact_id = 1, tracking_id = 0, slot_num = 0), True, True)], + + # t=1: Contact 0 == Down + !confident; Contact 1 == Down + confident + # First finger gains confidence + [(self.ContactIds(contact_id = 0, tracking_id = -1, slot_num = None), True, False), + (self.ContactIds(contact_id = 1, tracking_id = 0, slot_num = 0), True, True)], + + # t=2: Contact 0 == Down + confident; Contact 1 == Down + confident + # First finger remains confident + [(self.ContactIds(contact_id = 0, tracking_id = 1, slot_num = 1), True, True), + (self.ContactIds(contact_id = 1, tracking_id = 0, slot_num = 0), True, True)], + + # t=3: Contact 0 == Down + confident; Contact 1 == Down + confident + # First finger remains confident + [(self.ContactIds(contact_id = 0, tracking_id = 1, slot_num = 1), True, True), + (self.ContactIds(contact_id = 1, tracking_id = 0, slot_num = 0), True, True)] + ]) + + def test_confidence_gain_b(self): + """ + Transition a contact from non-confident to confident. + + Ensure that the confident contact is reported normally. + """ + uhdev = self.uhdev + evdev = uhdev.get_evdev() + + self.confidence_change_assert_playback(uhdev, evdev, [ + # t=0: Contact 0 == Down + confident; Contact 1 == Down + confident + # First and second finger confidently in contact + [(self.ContactIds(contact_id = 0, tracking_id = 0, slot_num = 0), True, True), + (self.ContactIds(contact_id = 1, tracking_id = 1, slot_num = 1), True, True)], + + # t=1: Contact 0 == Down + !confident; Contact 1 == Down + confident + # Firtst finger looses confidence + [(self.ContactIds(contact_id = 0, tracking_id = -1, slot_num = 0), True, False), + (self.ContactIds(contact_id = 1, tracking_id = 1, slot_num = 1), True, True)], + + # t=2: Contact 0 == Down + confident; Contact 1 == Down + confident + # First finger gains confidence + [(self.ContactIds(contact_id = 0, tracking_id = 2, slot_num = 0), True, True), + (self.ContactIds(contact_id = 1, tracking_id = 1, slot_num = 1), True, True)], + + # t=3: Contact 0 == !Down + confident; Contact 1 == Down + confident + # First finger goes up + [(self.ContactIds(contact_id = 0, tracking_id = -1, slot_num = 0), False, True), + (self.ContactIds(contact_id = 1, tracking_id = 1, slot_num = 1), True, True)] + ]) |