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# Copyright (c) 2020 Wacom Technology Corp.
8#
9# Authors:
10#     Jason Gerecke <jason.gerecke@wacom.com>
11
12"""
13Tests for the Wacom driver generic codepath.
14
15This module tests the function of the Wacom driver's generic codepath.
16The generic codepath is used by devices which are not explicitly listed
17in the driver's device table. It uses the device's HID descriptor to
18decode reports sent by the device.
19"""
20
21from .descriptors_wacom import (
22    wacom_pth660_v145,
23    wacom_pth660_v150,
24    wacom_pth860_v145,
25    wacom_pth860_v150,
26    wacom_pth460_v105,
27)
28
29import attr
30from enum import Enum
31from hidtools.hut import HUT
32from hidtools.hid import HidUnit
33from . import base
34import libevdev
35import pytest
36
37import logging
38
39logger = logging.getLogger("hidtools.test.wacom")
40
41KERNEL_MODULE = ("wacom", "wacom")
42
43
44class ProximityState(Enum):
45    """
46    Enumeration of allowed proximity states.
47    """
48
49    # Tool is not able to be sensed by the device
50    OUT = 0
51
52    # Tool is close enough to be sensed, but some data may be invalid
53    # or inaccurate
54    IN_PROXIMITY = 1
55
56    # Tool is close enough to be sensed with high accuracy. All data
57    # valid.
58    IN_RANGE = 2
59
60    def fill(self, reportdata):
61        """Fill a report with approrpiate HID properties/values."""
62        reportdata.inrange = self in [ProximityState.IN_RANGE]
63        reportdata.wacomsense = self in [
64            ProximityState.IN_PROXIMITY,
65            ProximityState.IN_RANGE,
66        ]
67
68
69class ReportData:
70    """
71    Placeholder for HID report values.
72    """
73
74    pass
75
76
77@attr.s
78class Buttons:
79    """
80    Stylus button state.
81
82    Describes the state of each of the buttons / "side switches" that
83    may be present on a stylus. Buttons set to 'None' indicate the
84    state is "unchanged" since the previous event.
85    """
86
87    primary = attr.ib(default=None)
88    secondary = attr.ib(default=None)
89    tertiary = attr.ib(default=None)
90
91    @staticmethod
92    def clear():
93        """Button object with all states cleared."""
94        return Buttons(False, False, False)
95
96    def fill(self, reportdata):
97        """Fill a report with approrpiate HID properties/values."""
98        reportdata.barrelswitch = int(self.primary or 0)
99        reportdata.secondarybarrelswitch = int(self.secondary or 0)
100        reportdata.b3 = int(self.tertiary or 0)
101
102
103@attr.s
104class ToolID:
105    """
106    Stylus tool identifiers.
107
108    Contains values used to identify a specific stylus, e.g. its serial
109    number and tool-type identifier. Values of ``0`` may sometimes be
110    used for the out-of-range condition.
111    """
112
113    serial = attr.ib()
114    tooltype = attr.ib()
115
116    @staticmethod
117    def clear():
118        """ToolID object with all fields cleared."""
119        return ToolID(0, 0)
120
121    def fill(self, reportdata):
122        """Fill a report with approrpiate HID properties/values."""
123        reportdata.transducerserialnumber = self.serial & 0xFFFFFFFF
124        reportdata.serialhi = (self.serial >> 32) & 0xFFFFFFFF
125        reportdata.tooltype = self.tooltype
126
127
128@attr.s
129class PhysRange:
130    """
131    Range of HID physical values, with units.
132    """
133
134    unit = attr.ib()
135    min_size = attr.ib()
136    max_size = attr.ib()
137
138    CENTIMETER = HidUnit.from_string("SILinear: cm")
139    DEGREE = HidUnit.from_string("EnglishRotation: deg")
140
141    def contains(self, field):
142        """
143        Check if the physical size of the provided field is in range.
144
145        Compare the physical size described by the provided HID field
146        against the range of sizes described by this object. This is
147        an exclusive range comparison (e.g. 0 cm is not within the
148        range 0 cm - 5 cm) and exact unit comparison (e.g. 1 inch is
149        not within the range 0 cm - 5 cm).
150        """
151        phys_size = (field.physical_max - field.physical_min) * 10 ** (field.unit_exp)
152        return (
153            field.unit == self.unit.value
154            and phys_size > self.min_size
155            and phys_size < self.max_size
156        )
157
158
159class BaseTablet(base.UHIDTestDevice):
160    """
161    Skeleton object for all kinds of tablet devices.
162    """
163
164    def __init__(self, rdesc, name=None, info=None):
165        assert rdesc is not None
166        super().__init__(name, "Pen", input_info=info, rdesc=rdesc)
167        self.buttons = Buttons.clear()
168        self.toolid = ToolID.clear()
169        self.proximity = ProximityState.OUT
170        self.offset = 0
171        self.ring = -1
172        self.ek0 = False
173
174    def match_evdev_rule(self, application, evdev):
175        """
176        Filter out evdev nodes based on the requested application.
177
178        The Wacom driver may create several device nodes for each USB
179        interface device. It is crucial that we run tests with the
180        expected device node or things will obviously go off the rails.
181        Use the Wacom driver's usual naming conventions to apply a
182        sensible default filter.
183        """
184        if application in ["Pen", "Pad"]:
185            return evdev.name.endswith(application)
186        else:
187            return True
188
189    def create_report(
190        self, x, y, pressure, buttons=None, toolid=None, proximity=None, reportID=None
191    ):
192        """
193        Return an input report for this device.
194
195        :param x: absolute x
196        :param y: absolute y
197        :param pressure: pressure
198        :param buttons: stylus button state. Use ``None`` for unchanged.
199        :param toolid: tool identifiers. Use ``None`` for unchanged.
200        :param proximity: a ProximityState indicating the sensor's ability
201             to detect and report attributes of this tool. Use ``None``
202             for unchanged.
203        :param reportID: the numeric report ID for this report, if needed
204        """
205        if buttons is not None:
206            self.buttons = buttons
207        buttons = self.buttons
208
209        if toolid is not None:
210            self.toolid = toolid
211        toolid = self.toolid
212
213        if proximity is not None:
214            self.proximity = proximity
215        proximity = self.proximity
216
217        reportID = reportID or self.default_reportID
218
219        report = ReportData()
220        report.x = x
221        report.y = y
222        report.tippressure = pressure
223        report.tipswitch = pressure > 0
224        buttons.fill(report)
225        proximity.fill(report)
226        toolid.fill(report)
227
228        return super().create_report(report, reportID=reportID)
229
230    def create_report_heartbeat(self, reportID):
231        """
232        Return a heartbeat input report for this device.
233
234        Heartbeat reports generally contain battery status information,
235        among other things.
236        """
237        report = ReportData()
238        report.wacombatterycharging = 1
239        return super().create_report(report, reportID=reportID)
240
241    def create_report_pad(self, reportID, ring, ek0):
242        report = ReportData()
243
244        if ring is not None:
245            self.ring = ring
246        ring = self.ring
247
248        if ek0 is not None:
249            self.ek0 = ek0
250        ek0 = self.ek0
251
252        if ring >= 0:
253            report.wacomtouchring = ring
254            report.wacomtouchringstatus = 1
255        else:
256            report.wacomtouchring = 0x7F
257            report.wacomtouchringstatus = 0
258
259        report.wacomexpresskey00 = ek0
260        return super().create_report(report, reportID=reportID)
261
262    def event(self, x, y, pressure, buttons=None, toolid=None, proximity=None):
263        """
264        Send an input event on the default report ID.
265
266        :param x: absolute x
267        :param y: absolute y
268        :param buttons: stylus button state. Use ``None`` for unchanged.
269        :param toolid: tool identifiers. Use ``None`` for unchanged.
270        :param proximity: a ProximityState indicating the sensor's ability
271             to detect and report attributes of this tool. Use ``None``
272             for unchanged.
273        """
274        r = self.create_report(x, y, pressure, buttons, toolid, proximity)
275        self.call_input_event(r)
276        return [r]
277
278    def event_heartbeat(self, reportID):
279        """
280        Send a heartbeat event on the requested report ID.
281        """
282        r = self.create_report_heartbeat(reportID)
283        self.call_input_event(r)
284        return [r]
285
286    def event_pad(self, reportID, ring=None, ek0=None):
287        """
288        Send a pad event on the requested report ID.
289        """
290        r = self.create_report_pad(reportID, ring, ek0)
291        self.call_input_event(r)
292        return [r]
293
294    def get_report(self, req, rnum, rtype):
295        if rtype != self.UHID_FEATURE_REPORT:
296            return (1, [])
297
298        rdesc = None
299        for v in self.parsed_rdesc.feature_reports.values():
300            if v.report_ID == rnum:
301                rdesc = v
302
303        if rdesc is None:
304            return (1, [])
305
306        result = (1, [])
307        result = self.create_report_offset(rdesc) or result
308        return result
309
310    def create_report_offset(self, rdesc):
311        require = [
312            "Wacom Offset Left",
313            "Wacom Offset Top",
314            "Wacom Offset Right",
315            "Wacom Offset Bottom",
316        ]
317        if not set(require).issubset(set([f.usage_name for f in rdesc])):
318            return None
319
320        report = ReportData()
321        report.wacomoffsetleft = self.offset
322        report.wacomoffsettop = self.offset
323        report.wacomoffsetright = self.offset
324        report.wacomoffsetbottom = self.offset
325        r = rdesc.create_report([report], None)
326        return (0, r)
327
328
329class OpaqueTablet(BaseTablet):
330    """
331    Bare-bones opaque tablet with a minimum of features.
332
333    A tablet stripped down to its absolute core. It is capable of
334    reporting X/Y position and if the pen is in contact. No pressure,
335    no barrel switches, no eraser. Notably it *does* report an "In
336    Range" flag, but this is only because the Wacom driver expects
337    one to function properly. The device uses only standard HID usages,
338    not any of Wacom's vendor-defined pages.
339    """
340
341    # fmt: off
342    report_descriptor = [
343        0x05, 0x0D,                     # . Usage Page (Digitizer),
344        0x09, 0x01,                     # . Usage (Digitizer),
345        0xA1, 0x01,                     # . Collection (Application),
346        0x85, 0x01,                     # .     Report ID (1),
347        0x09, 0x20,                     # .     Usage (Stylus),
348        0xA1, 0x00,                     # .     Collection (Physical),
349        0x09, 0x42,                     # .         Usage (Tip Switch),
350        0x09, 0x32,                     # .         Usage (In Range),
351        0x15, 0x00,                     # .         Logical Minimum (0),
352        0x25, 0x01,                     # .         Logical Maximum (1),
353        0x75, 0x01,                     # .         Report Size (1),
354        0x95, 0x02,                     # .         Report Count (2),
355        0x81, 0x02,                     # .         Input (Variable),
356        0x95, 0x06,                     # .         Report Count (6),
357        0x81, 0x03,                     # .         Input (Constant, Variable),
358        0x05, 0x01,                     # .         Usage Page (Desktop),
359        0x09, 0x30,                     # .         Usage (X),
360        0x27, 0x80, 0x3E, 0x00, 0x00,   # .         Logical Maximum (16000),
361        0x47, 0x80, 0x3E, 0x00, 0x00,   # .         Physical Maximum (16000),
362        0x65, 0x11,                     # .         Unit (Centimeter),
363        0x55, 0x0D,                     # .         Unit Exponent (13),
364        0x75, 0x10,                     # .         Report Size (16),
365        0x95, 0x01,                     # .         Report Count (1),
366        0x81, 0x02,                     # .         Input (Variable),
367        0x09, 0x31,                     # .         Usage (Y),
368        0x27, 0x28, 0x23, 0x00, 0x00,   # .         Logical Maximum (9000),
369        0x47, 0x28, 0x23, 0x00, 0x00,   # .         Physical Maximum (9000),
370        0x81, 0x02,                     # .         Input (Variable),
371        0xC0,                           # .     End Collection,
372        0xC0,                           # . End Collection,
373    ]
374    # fmt: on
375
376    def __init__(self, rdesc=report_descriptor, name=None, info=(0x3, 0x056A, 0x9999)):
377        super().__init__(rdesc, name, info)
378        self.default_reportID = 1
379
380
381class OpaqueCTLTablet(BaseTablet):
382    """
383    Opaque tablet similar to something in the CTL product line.
384
385    A pen-only tablet with most basic features you would expect from
386    an actual device. Position, eraser, pressure, barrel buttons.
387    Uses the Wacom vendor-defined usage page.
388    """
389
390    # fmt: off
391    report_descriptor = [
392        0x06, 0x0D, 0xFF,               # . Usage Page (Vnd Wacom Emr),
393        0x09, 0x01,                     # . Usage (Digitizer),
394        0xA1, 0x01,                     # . Collection (Application),
395        0x85, 0x10,                     # .     Report ID (16),
396        0x09, 0x20,                     # .     Usage (Stylus),
397        0x35, 0x00,                     # .     Physical Minimum (0),
398        0x45, 0x00,                     # .     Physical Maximum (0),
399        0x15, 0x00,                     # .     Logical Minimum (0),
400        0x25, 0x01,                     # .     Logical Maximum (1),
401        0xA1, 0x00,                     # .     Collection (Physical),
402        0x09, 0x42,                     # .         Usage (Tip Switch),
403        0x09, 0x44,                     # .         Usage (Barrel Switch),
404        0x09, 0x5A,                     # .         Usage (Secondary Barrel Switch),
405        0x09, 0x45,                     # .         Usage (Eraser),
406        0x09, 0x3C,                     # .         Usage (Invert),
407        0x09, 0x32,                     # .         Usage (In Range),
408        0x09, 0x36,                     # .         Usage (In Proximity),
409        0x25, 0x01,                     # .         Logical Maximum (1),
410        0x75, 0x01,                     # .         Report Size (1),
411        0x95, 0x07,                     # .         Report Count (7),
412        0x81, 0x02,                     # .         Input (Variable),
413        0x95, 0x01,                     # .         Report Count (1),
414        0x81, 0x03,                     # .         Input (Constant, Variable),
415        0x0A, 0x30, 0x01,               # .         Usage (X),
416        0x65, 0x11,                     # .         Unit (Centimeter),
417        0x55, 0x0D,                     # .         Unit Exponent (13),
418        0x47, 0x80, 0x3E, 0x00, 0x00,   # .         Physical Maximum (16000),
419        0x27, 0x80, 0x3E, 0x00, 0x00,   # .         Logical Maximum (16000),
420        0x75, 0x18,                     # .         Report Size (24),
421        0x95, 0x01,                     # .         Report Count (1),
422        0x81, 0x02,                     # .         Input (Variable),
423        0x0A, 0x31, 0x01,               # .         Usage (Y),
424        0x47, 0x28, 0x23, 0x00, 0x00,   # .         Physical Maximum (9000),
425        0x27, 0x28, 0x23, 0x00, 0x00,   # .         Logical Maximum (9000),
426        0x81, 0x02,                     # .         Input (Variable),
427        0x09, 0x30,                     # .         Usage (Tip Pressure),
428        0x55, 0x00,                     # .         Unit Exponent (0),
429        0x65, 0x00,                     # .         Unit,
430        0x47, 0x00, 0x00, 0x00, 0x00,   # .         Physical Maximum (0),
431        0x26, 0xFF, 0x0F,               # .         Logical Maximum (4095),
432        0x75, 0x10,                     # .         Report Size (16),
433        0x81, 0x02,                     # .         Input (Variable),
434        0x75, 0x08,                     # .         Report Size (8),
435        0x95, 0x06,                     # .         Report Count (6),
436        0x81, 0x03,                     # .         Input (Constant, Variable),
437        0x0A, 0x32, 0x01,               # .         Usage (Z),
438        0x25, 0x3F,                     # .         Logical Maximum (63),
439        0x75, 0x08,                     # .         Report Size (8),
440        0x95, 0x01,                     # .         Report Count (1),
441        0x81, 0x02,                     # .         Input (Variable),
442        0x09, 0x5B,                     # .         Usage (Transducer Serial Number),
443        0x09, 0x5C,                     # .         Usage (Transducer Serial Number Hi),
444        0x17, 0x00, 0x00, 0x00, 0x80,   # .         Logical Minimum (-2147483648),
445        0x27, 0xFF, 0xFF, 0xFF, 0x7F,   # .         Logical Maximum (2147483647),
446        0x75, 0x20,                     # .         Report Size (32),
447        0x95, 0x02,                     # .         Report Count (2),
448        0x81, 0x02,                     # .         Input (Variable),
449        0x09, 0x77,                     # .         Usage (Tool Type),
450        0x15, 0x00,                     # .         Logical Minimum (0),
451        0x26, 0xFF, 0x0F,               # .         Logical Maximum (4095),
452        0x75, 0x10,                     # .         Report Size (16),
453        0x95, 0x01,                     # .         Report Count (1),
454        0x81, 0x02,                     # .         Input (Variable),
455        0xC0,                           # .     End Collection,
456        0xC0                            # . End Collection
457    ]
458    # fmt: on
459
460    def __init__(self, rdesc=report_descriptor, name=None, info=(0x3, 0x056A, 0x9999)):
461        super().__init__(rdesc, name, info)
462        self.default_reportID = 16
463
464
465class PTHX60_Pen(BaseTablet):
466    """
467    Pen interface of a PTH-660 / PTH-860 / PTH-460 tablet.
468
469    This generation of devices are nearly identical to each other, though
470    the PTH-460 uses a slightly different descriptor construction (splits
471    the pad among several physical collections)
472    """
473
474    def __init__(self, rdesc=None, name=None, info=None):
475        super().__init__(rdesc, name, info)
476        self.default_reportID = 16
477
478
479class BaseTest:
480    class TestTablet(base.BaseTestCase.TestUhid):
481        kernel_modules = [KERNEL_MODULE]
482
483        def sync_and_assert_events(
484            self, report, expected_events, auto_syn=True, strict=False
485        ):
486            """
487            Assert we see the expected events in response to a report.
488            """
489            uhdev = self.uhdev
490            syn_event = self.syn_event
491            if auto_syn:
492                expected_events.append(syn_event)
493            actual_events = uhdev.next_sync_events()
494            self.debug_reports(report, uhdev, actual_events)
495            if strict:
496                self.assertInputEvents(expected_events, actual_events)
497            else:
498                self.assertInputEventsIn(expected_events, actual_events)
499
500        def get_usages(self, uhdev):
501            def get_report_usages(report):
502                application = report.application
503                for field in report.fields:
504                    if field.usages is not None:
505                        for usage in field.usages:
506                            yield (field, usage, application)
507                    else:
508                        yield (field, field.usage, application)
509
510            desc = uhdev.parsed_rdesc
511            reports = [
512                *desc.input_reports.values(),
513                *desc.feature_reports.values(),
514                *desc.output_reports.values(),
515            ]
516            for report in reports:
517                for usage in get_report_usages(report):
518                    yield usage
519
520        def assertName(self, uhdev):
521            """
522            Assert that the name is as we expect.
523
524            The Wacom driver applies a number of decorations to the name
525            provided by the hardware. We cannot rely on the definition of
526            this assertion from the base class to work properly.
527            """
528            evdev = uhdev.get_evdev()
529            expected_name = uhdev.name + " Pen"
530            if "wacom" not in expected_name.lower():
531                expected_name = "Wacom " + expected_name
532            assert evdev.name == expected_name
533
534        def test_descriptor_physicals(self):
535            """
536            Verify that all HID usages which should have a physical range
537            actually do, and those which shouldn't don't. Also verify that
538            the associated unit is correct and within a sensible range.
539            """
540
541            def usage_id(page_name, usage_name):
542                page = HUT.usage_page_from_name(page_name)
543                return (page.page_id << 16) | page[usage_name].usage
544
545            required = {
546                usage_id("Generic Desktop", "X"): PhysRange(
547                    PhysRange.CENTIMETER, 5, 150
548                ),
549                usage_id("Generic Desktop", "Y"): PhysRange(
550                    PhysRange.CENTIMETER, 5, 150
551                ),
552                usage_id("Digitizers", "X Tilt"): PhysRange(PhysRange.DEGREE, 90, 180),
553                usage_id("Digitizers", "Y Tilt"): PhysRange(PhysRange.DEGREE, 90, 180),
554                usage_id("Digitizers", "Twist"): PhysRange(PhysRange.DEGREE, 358, 360),
555                usage_id("Wacom", "X Tilt"): PhysRange(PhysRange.DEGREE, 90, 180),
556                usage_id("Wacom", "Y Tilt"): PhysRange(PhysRange.DEGREE, 90, 180),
557                usage_id("Wacom", "Twist"): PhysRange(PhysRange.DEGREE, 358, 360),
558                usage_id("Wacom", "X"): PhysRange(PhysRange.CENTIMETER, 5, 150),
559                usage_id("Wacom", "Y"): PhysRange(PhysRange.CENTIMETER, 5, 150),
560                usage_id("Wacom", "Wacom TouchRing"): PhysRange(
561                    PhysRange.DEGREE, 358, 360
562                ),
563                usage_id("Wacom", "Wacom Offset Left"): PhysRange(
564                    PhysRange.CENTIMETER, 0, 0.5
565                ),
566                usage_id("Wacom", "Wacom Offset Top"): PhysRange(
567                    PhysRange.CENTIMETER, 0, 0.5
568                ),
569                usage_id("Wacom", "Wacom Offset Right"): PhysRange(
570                    PhysRange.CENTIMETER, 0, 0.5
571                ),
572                usage_id("Wacom", "Wacom Offset Bottom"): PhysRange(
573                    PhysRange.CENTIMETER, 0, 0.5
574                ),
575            }
576            for field, usage, application in self.get_usages(self.uhdev):
577                if application == usage_id("Generic Desktop", "Mouse"):
578                    # Ignore the vestigial Mouse collection which exists
579                    # on Wacom tablets only for backwards compatibility.
580                    continue
581
582                expect_physical = usage in required
583
584                phys_set = field.physical_min != 0 or field.physical_max != 0
585                assert phys_set == expect_physical
586
587                unit_set = field.unit != 0
588                assert unit_set == expect_physical
589
590                if unit_set:
591                    assert required[usage].contains(field)
592
593        def test_prop_direct(self):
594            """
595            Todo: Verify that INPUT_PROP_DIRECT is set on display devices.
596            """
597            pass
598
599        def test_prop_pointer(self):
600            """
601            Todo: Verify that INPUT_PROP_POINTER is set on opaque devices.
602            """
603            pass
604
605
606class TestOpaqueTablet(BaseTest.TestTablet):
607    def create_device(self):
608        return OpaqueTablet()
609
610    def test_sanity(self):
611        """
612        Bring a pen into contact with the tablet, then remove it.
613
614        Ensure that we get the basic tool/touch/motion events that should
615        be sent by the driver.
616        """
617        uhdev = self.uhdev
618
619        self.sync_and_assert_events(
620            uhdev.event(
621                100,
622                200,
623                pressure=300,
624                buttons=Buttons.clear(),
625                toolid=ToolID(serial=1, tooltype=1),
626                proximity=ProximityState.IN_RANGE,
627            ),
628            [
629                libevdev.InputEvent(libevdev.EV_KEY.BTN_TOOL_PEN, 1),
630                libevdev.InputEvent(libevdev.EV_ABS.ABS_X, 100),
631                libevdev.InputEvent(libevdev.EV_ABS.ABS_Y, 200),
632                libevdev.InputEvent(libevdev.EV_KEY.BTN_TOUCH, 1),
633            ],
634        )
635
636        self.sync_and_assert_events(
637            uhdev.event(110, 220, pressure=0),
638            [
639                libevdev.InputEvent(libevdev.EV_ABS.ABS_X, 110),
640                libevdev.InputEvent(libevdev.EV_ABS.ABS_Y, 220),
641                libevdev.InputEvent(libevdev.EV_KEY.BTN_TOUCH, 0),
642            ],
643        )
644
645        self.sync_and_assert_events(
646            uhdev.event(
647                120,
648                230,
649                pressure=0,
650                toolid=ToolID.clear(),
651                proximity=ProximityState.OUT,
652            ),
653            [
654                libevdev.InputEvent(libevdev.EV_KEY.BTN_TOOL_PEN, 0),
655            ],
656        )
657
658        self.sync_and_assert_events(
659            uhdev.event(130, 240, pressure=0), [], auto_syn=False, strict=True
660        )
661
662
663class TestOpaqueCTLTablet(TestOpaqueTablet):
664    def create_device(self):
665        return OpaqueCTLTablet()
666
667    def test_buttons(self):
668        """
669        Test that the barrel buttons (side switches) work as expected.
670
671        Press and release each button individually to verify that we get
672        the expected events.
673        """
674        uhdev = self.uhdev
675
676        self.sync_and_assert_events(
677            uhdev.event(
678                100,
679                200,
680                pressure=0,
681                buttons=Buttons.clear(),
682                toolid=ToolID(serial=1, tooltype=1),
683                proximity=ProximityState.IN_RANGE,
684            ),
685            [
686                libevdev.InputEvent(libevdev.EV_KEY.BTN_TOOL_PEN, 1),
687                libevdev.InputEvent(libevdev.EV_ABS.ABS_X, 100),
688                libevdev.InputEvent(libevdev.EV_ABS.ABS_Y, 200),
689                libevdev.InputEvent(libevdev.EV_MSC.MSC_SERIAL, 1),
690            ],
691        )
692
693        self.sync_and_assert_events(
694            uhdev.event(100, 200, pressure=0, buttons=Buttons(primary=True)),
695            [
696                libevdev.InputEvent(libevdev.EV_KEY.BTN_STYLUS, 1),
697                libevdev.InputEvent(libevdev.EV_MSC.MSC_SERIAL, 1),
698            ],
699        )
700
701        self.sync_and_assert_events(
702            uhdev.event(100, 200, pressure=0, buttons=Buttons(primary=False)),
703            [
704                libevdev.InputEvent(libevdev.EV_KEY.BTN_STYLUS, 0),
705                libevdev.InputEvent(libevdev.EV_MSC.MSC_SERIAL, 1),
706            ],
707        )
708
709        self.sync_and_assert_events(
710            uhdev.event(100, 200, pressure=0, buttons=Buttons(secondary=True)),
711            [
712                libevdev.InputEvent(libevdev.EV_KEY.BTN_STYLUS2, 1),
713                libevdev.InputEvent(libevdev.EV_MSC.MSC_SERIAL, 1),
714            ],
715        )
716
717        self.sync_and_assert_events(
718            uhdev.event(100, 200, pressure=0, buttons=Buttons(secondary=False)),
719            [
720                libevdev.InputEvent(libevdev.EV_KEY.BTN_STYLUS2, 0),
721                libevdev.InputEvent(libevdev.EV_MSC.MSC_SERIAL, 1),
722            ],
723        )
724
725
726PTHX60_Devices = [
727    {"rdesc": wacom_pth660_v145, "info": (0x3, 0x056A, 0x0357)},
728    {"rdesc": wacom_pth660_v150, "info": (0x3, 0x056A, 0x0357)},
729    {"rdesc": wacom_pth860_v145, "info": (0x3, 0x056A, 0x0358)},
730    {"rdesc": wacom_pth860_v150, "info": (0x3, 0x056A, 0x0358)},
731    {"rdesc": wacom_pth460_v105, "info": (0x3, 0x056A, 0x0392)},
732]
733
734PTHX60_Names = [
735    "PTH-660/v145",
736    "PTH-660/v150",
737    "PTH-860/v145",
738    "PTH-860/v150",
739    "PTH-460/v105",
740]
741
742
743class TestPTHX60_Pen(TestOpaqueCTLTablet):
744    @pytest.fixture(
745        autouse=True, scope="class", params=PTHX60_Devices, ids=PTHX60_Names
746    )
747    def set_device_params(self, request):
748        request.cls.device_params = request.param
749
750    def create_device(self):
751        return PTHX60_Pen(**self.device_params)
752
753    @pytest.mark.xfail
754    def test_descriptor_physicals(self):
755        # XFAIL: Various documented errata
756        super().test_descriptor_physicals()
757
758    def test_heartbeat_spurious(self):
759        """
760        Test that the heartbeat report does not send spurious events.
761        """
762        uhdev = self.uhdev
763
764        self.sync_and_assert_events(
765            uhdev.event(
766                100,
767                200,
768                pressure=300,
769                buttons=Buttons.clear(),
770                toolid=ToolID(serial=1, tooltype=0x822),
771                proximity=ProximityState.IN_RANGE,
772            ),
773            [
774                libevdev.InputEvent(libevdev.EV_KEY.BTN_TOOL_PEN, 1),
775                libevdev.InputEvent(libevdev.EV_ABS.ABS_X, 100),
776                libevdev.InputEvent(libevdev.EV_ABS.ABS_Y, 200),
777                libevdev.InputEvent(libevdev.EV_KEY.BTN_TOUCH, 1),
778            ],
779        )
780
781        # Exactly zero events: not even a SYN
782        self.sync_and_assert_events(
783            uhdev.event_heartbeat(19), [], auto_syn=False, strict=True
784        )
785
786        self.sync_and_assert_events(
787            uhdev.event(110, 200, pressure=300),
788            [
789                libevdev.InputEvent(libevdev.EV_ABS.ABS_X, 110),
790            ],
791        )
792
793    def test_empty_pad_sync(self):
794        self.empty_pad_sync(num=3, denom=16, reverse=True)
795
796    def empty_pad_sync(self, num, denom, reverse):
797        """
798        Test that multiple pad collections do not trigger empty syncs.
799        """
800
801        def offset_rotation(value):
802            """
803            Offset touchring rotation values by the same factor as the
804            Linux kernel. Tablets historically don't use the same origin
805            as HID, and it sometimes changes from tablet to tablet...
806            """
807            evdev = self.uhdev.get_evdev()
808            info = evdev.absinfo[libevdev.EV_ABS.ABS_WHEEL]
809            delta = info.maximum - info.minimum + 1
810            if reverse:
811                value = info.maximum - value
812            value += num * delta // denom
813            if value > info.maximum:
814                value -= delta
815            elif value < info.minimum:
816                value += delta
817            return value
818
819        uhdev = self.uhdev
820        uhdev.application = "Pad"
821        evdev = uhdev.get_evdev()
822
823        print(evdev.name)
824        self.sync_and_assert_events(
825            uhdev.event_pad(reportID=17, ring=0, ek0=1),
826            [
827                libevdev.InputEvent(libevdev.EV_KEY.BTN_0, 1),
828                libevdev.InputEvent(libevdev.EV_ABS.ABS_WHEEL, offset_rotation(0)),
829                libevdev.InputEvent(libevdev.EV_ABS.ABS_MISC, 15),
830            ],
831        )
832
833        self.sync_and_assert_events(
834            uhdev.event_pad(reportID=17, ring=1, ek0=1),
835            [libevdev.InputEvent(libevdev.EV_ABS.ABS_WHEEL, offset_rotation(1))],
836        )
837
838        self.sync_and_assert_events(
839            uhdev.event_pad(reportID=17, ring=2, ek0=0),
840            [
841                libevdev.InputEvent(libevdev.EV_ABS.ABS_WHEEL, offset_rotation(2)),
842                libevdev.InputEvent(libevdev.EV_KEY.BTN_0, 0),
843            ],
844        )
845