1#!/usr/local/bin/python3.8
2# vim: set expandtab shiftwidth=4:
3# -*- Mode: python; coding: utf-8; indent-tabs-mode: nil -*- */
4#
5# Copyright © 2017 Red Hat, Inc.
6#
7# Permission is hereby granted, free of charge, to any person obtaining a
8# copy of this software and associated documentation files (the "Software"),
9# to deal in the Software without restriction, including without limitation
10# the rights to use, copy, modify, merge, publish, distribute, sublicense,
11# and/or sell copies of the Software, and to permit persons to whom the
12# Software is furnished to do so, subject to the following conditions:
13#
14# The above copyright notice and this permission notice (including the next
15# paragraph) shall be included in all copies or substantial portions of the
16# Software.
17#
18# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
21# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
24# DEALINGS IN THE SOFTWARE.
25#
26
27import sys
28import subprocess
29import argparse
30try:
31    import evdev
32    import evdev.ecodes
33    import pyudev
34except ModuleNotFoundError as e:
35    print('Error: {}'.format(str(e)), file=sys.stderr)
36    print('One or more python modules are missing. Please install those '
37          'modules and re-run this tool.')
38    sys.exit(1)
39
40
41class Range(object):
42    """Class to keep a min/max of a value around"""
43    def __init__(self):
44        self.min = float('inf')
45        self.max = float('-inf')
46
47    def update(self, value):
48        self.min = min(self.min, value)
49        self.max = max(self.max, value)
50
51
52class Touch(object):
53    """A single data point of a sequence (i.e. one event frame)"""
54
55    def __init__(self, major=None, minor=None, orientation=None):
56        self._major = major
57        self._minor = minor
58        self._orientation = orientation
59        self.dirty = False
60
61    @property
62    def major(self):
63        return self._major
64
65    @major.setter
66    def major(self, major):
67        self._major = major
68        self.dirty = True
69
70    @property
71    def minor(self):
72        return self._minor
73
74    @minor.setter
75    def minor(self, minor):
76        self._minor = minor
77        self.dirty = True
78
79    @property
80    def orientation(self):
81        return self._orientation
82
83    @orientation.setter
84    def orientation(self, orientation):
85        self._orientation = orientation
86        self.dirty = True
87
88    def __str__(self):
89        s = "Touch: major {:3d}".format(self.major)
90        if self.minor is not None:
91                s += ", minor {:3d}".format(self.minor)
92        if self.orientation is not None:
93                s += ", orientation {:+3d}".format(self.orientation)
94        return s
95
96
97class TouchSequence(object):
98    """A touch sequence from beginning to end"""
99
100    def __init__(self, device, tracking_id):
101        self.device = device
102        self.tracking_id = tracking_id
103        self.points = []
104
105        self.is_active = True
106
107        self.is_down = False
108        self.was_down = False
109        self.is_palm = False
110        self.was_palm = False
111        self.is_thumb = False
112        self.was_thumb = False
113
114        self.major_range = Range()
115        self.minor_range = Range()
116
117    def append(self, touch):
118        """Add a Touch to the sequence"""
119        self.points.append(touch)
120        self.major_range.update(touch.major)
121        self.minor_range.update(touch.minor)
122
123        if touch.major < self.device.up or \
124           touch.minor < self.device.up:
125                self.is_down = False
126        elif touch.major > self.device.down or \
127                touch.minor > self.device.down:
128            self.is_down = True
129            self.was_down = True
130
131        self.is_palm = touch.major > self.device.palm
132        if self.is_palm:
133                self.was_palm = True
134
135        self.is_thumb = self.device.thumb != 0 and touch.major > self.device.thumb
136        if self.is_thumb:
137            self.was_thumb = True
138
139    def finalize(self):
140        """Mark the TouchSequence as complete (finger is up)"""
141        self.is_active = False
142
143    def __str__(self):
144        return self._str_state() if self.is_active else self._str_summary()
145
146    def _str_summary(self):
147        if not self.points:
148            return "{:78s}".format("Sequence: no major/minor values recorded")
149
150        s = "Sequence: major: [{:3d}..{:3d}] ".format(
151                self.major_range.min, self.major_range.max
152        )
153        if self.device.has_minor:
154                s += "minor: [{:3d}..{:3d}] ".format(
155                        self.minor_range.min, self.minor_range.max
156                     )
157        if self.was_down:
158                s += " down"
159        if self.was_palm:
160                s += " palm"
161        if self.was_thumb:
162            s += " thumb"
163
164        return s
165
166    def _str_state(self):
167        touch = self.points[-1]
168        s = "{}, tags: {} {} {}".format(
169                                touch,
170                                "down" if self.is_down else "    ",
171                                "palm" if self.is_palm else "    ",
172                                "thumb" if self.is_thumb else "     "
173                                )
174        return s
175
176
177class InvalidDeviceError(Exception):
178    pass
179
180
181class Device(object):
182    def __init__(self, path):
183        if path is None:
184            self.path = self.find_touch_device()
185        else:
186            self.path = path
187
188        self.device = evdev.InputDevice(self.path)
189
190        print("Using {}: {}\n".format(self.device.name, self.path))
191
192        # capabilities returns a dict with the EV_* codes as key,
193        # each of which is a list of tuples of (code, AbsInfo)
194        #
195        # Get the abs list first (or empty list if missing),
196        # then extract the touch major absinfo from that
197        caps = self.device.capabilities(absinfo=True).get(
198                evdev.ecodes.EV_ABS, []
199               )
200        codes = [cap[0] for cap in caps]
201
202        if evdev.ecodes.ABS_MT_TOUCH_MAJOR not in codes:
203            raise InvalidDeviceError("device does not have ABS_MT_TOUCH_MAJOR")
204
205        self.has_minor = evdev.ecodes.ABS_MT_TOUCH_MINOR in codes
206        self.has_orientation = evdev.ecodes.ABS_MT_ORIENTATION in codes
207
208        self.up = 0
209        self.down = 0
210        self.palm = 0
211        self.thumb = 0
212
213        self._init_thresholds_from_quirks()
214        self.sequences = []
215        self.touch = Touch(0, 0)
216
217    def find_touch_device(self):
218        context = pyudev.Context()
219        for device in context.list_devices(subsystem='input'):
220            if not device.get('ID_INPUT_TOUCHPAD', 0) and \
221               not device.get('ID_INPUT_TOUCHSCREEN', 0):
222                continue
223
224            if not device.device_node or \
225                    not device.device_node.startswith('/dev/input/event'):
226                continue
227
228            return device.device_node
229
230        print("Unable to find a touch device.", file=sys.stderr)
231        sys.exit(1)
232
233    def _init_thresholds_from_quirks(self):
234        command = ['libinput', 'quirks', 'list', self.path]
235        cmd = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
236        if cmd.returncode != 0:
237            print("Error querying quirks: {}".format(cmd.stderr.decode('utf-8')), file=sys.stderr)
238            return
239
240        stdout = cmd.stdout.decode('utf-8')
241        quirks = [q.split('=') for q in stdout.split('\n')]
242
243        for q in quirks:
244            if q[0] == 'AttrPalmSizeThreshold':
245                self.palm = int(q[1])
246            elif q[0] == 'AttrTouchSizeRange':
247                self.down, self.up = colon_tuple(q[1])
248            elif q[0] == 'AttrThumbSizeThreshold':
249                self.thumb = int(q[1])
250
251    def start_new_sequence(self, tracking_id):
252        self.sequences.append(TouchSequence(self, tracking_id))
253
254    def current_sequence(self):
255        return self.sequences[-1]
256
257    def handle_key(self, event):
258        tapcodes = [evdev.ecodes.BTN_TOOL_DOUBLETAP,
259                    evdev.ecodes.BTN_TOOL_TRIPLETAP,
260                    evdev.ecodes.BTN_TOOL_QUADTAP,
261                    evdev.ecodes.BTN_TOOL_QUINTTAP]
262        if event.code in tapcodes and event.value > 0:
263                print("\rThis tool cannot handle multiple fingers, "
264                      "output will be invalid", file=sys.stderr)
265
266    def handle_abs(self, event):
267        if event.code == evdev.ecodes.ABS_MT_TRACKING_ID:
268                if event.value > -1:
269                    self.start_new_sequence(event.value)
270                else:
271                    try:
272                        s = self.current_sequence()
273                        s.finalize()
274                        print("\r{}".format(s))
275                    except IndexError:
276                        # If the finger was down during start
277                        pass
278        elif event.code == evdev.ecodes.ABS_MT_TOUCH_MAJOR:
279                self.touch.major = event.value
280        elif event.code == evdev.ecodes.ABS_MT_TOUCH_MINOR:
281                self.touch.minor = event.value
282        elif event.code == evdev.ecodes.ABS_MT_ORIENTATION:
283                self.touch.orientation = event.value
284
285    def handle_syn(self, event):
286        if self.touch.dirty:
287            try:
288                self.current_sequence().append(self.touch)
289                print("\r{}".format(self.current_sequence()), end="")
290                self.touch = Touch(major=self.touch.major,
291                                   minor=self.touch.minor,
292                                   orientation=self.touch.orientation)
293            except IndexError:
294                pass
295
296    def handle_event(self, event):
297        if event.type == evdev.ecodes.EV_ABS:
298            self.handle_abs(event)
299        elif event.type == evdev.ecodes.EV_KEY:
300            self.handle_key(event)
301        elif event.type == evdev.ecodes.EV_SYN:
302            self.handle_syn(event)
303
304    def read_events(self):
305        print("Ready for recording data.")
306        print("Touch sizes used: {}:{}".format(self.down, self.up))
307        print("Palm size used: {}".format(self.palm))
308        print("Thumb size used: {}".format(self.thumb))
309        print("Place a single finger on the device to measure touch size.\n"
310              "Ctrl+C to exit\n")
311
312        for event in self.device.read_loop():
313            self.handle_event(event)
314
315
316def colon_tuple(string):
317    try:
318        ts = string.split(':')
319        t = tuple([int(x) for x in ts])
320        if len(t) == 2 and t[0] >= t[1]:
321            return t
322    except:
323        pass
324
325    msg = "{} is not in format N:M (N >= M)".format(string)
326    raise argparse.ArgumentTypeError(msg)
327
328
329def main(args):
330    parser = argparse.ArgumentParser(
331            description="Measure touch size and orientation"
332    )
333    parser.add_argument('path', metavar='/dev/input/event0',
334                        nargs='?', type=str, help='Path to device (optional)')
335    parser.add_argument('--touch-thresholds', metavar='down:up',
336                        type=colon_tuple,
337                        help='Thresholds when a touch is logically down or up')
338    parser.add_argument('--palm-threshold', metavar='t',
339                        type=int, help='Threshold when a touch is a palm')
340    args = parser.parse_args()
341
342    try:
343        device = Device(args.path)
344
345        if args.touch_thresholds is not None:
346            device.down, device.up = args.touch_thresholds
347
348        if args.palm_threshold is not None:
349            device.palm = args.palm_threshold
350
351        device.read_events()
352    except KeyboardInterrupt:
353        pass
354    except (PermissionError, OSError):
355        print("Error: failed to open device")
356    except InvalidDeviceError as e:
357        print("Error: {}".format(e))
358
359
360if __name__ == "__main__":
361    main(sys.argv)
362