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