1#!/usr/bin/env python3 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 libevdev 32 import pyudev 33except ModuleNotFoundError as e: 34 print('Error: {}'.format(str(e)), file=sys.stderr) 35 print('One or more python modules are missing. Please install those ' 36 'modules and re-run this tool.') 37 sys.exit(1) 38 39 40class TableFormatter(object): 41 ALIGNMENT = 3 42 43 def __init__(self): 44 self.colwidths = [] 45 46 @property 47 def width(self): 48 return sum(self.colwidths) + 1 49 50 def headers(self, args): 51 s = '|' 52 align = self.ALIGNMENT - 1 # account for | 53 54 for arg in args: 55 # +2 because we want space left/right of text 56 w = ((len(arg) + 2 + align) // align) * align 57 self.colwidths.append(w + 1) 58 s += ' {:^{width}s} |'.format(arg, width=w - 2) 59 60 return s 61 62 def values(self, args): 63 s = '|' 64 for w, arg in zip(self.colwidths, args): 65 w -= 1 # width includes | separator 66 if type(arg) == str: 67 # We want space margins for strings 68 s += ' {:{width}s} |'.format(arg, width=w - 2) 69 elif type(arg) == bool: 70 s += '{:^{width}s}|'.format('x' if arg else ' ', width=w) 71 else: 72 s += '{:^{width}d}|'.format(arg, width=w) 73 74 if len(args) < len(self.colwidths): 75 s += '|'.rjust(self.width - len(s), ' ') 76 return s 77 78 def separator(self): 79 return '+' + '-' * (self.width - 2) + '+' 80 81 82fmt = TableFormatter() 83 84 85class Range(object): 86 """Class to keep a min/max of a value around""" 87 def __init__(self): 88 self.min = float('inf') 89 self.max = float('-inf') 90 91 def update(self, value): 92 self.min = min(self.min, value) 93 self.max = max(self.max, value) 94 95 96class Touch(object): 97 """A single data point of a sequence (i.e. one event frame)""" 98 99 def __init__(self, pressure=None): 100 self.pressure = pressure 101 102 103class TouchSequence(object): 104 """A touch sequence from beginning to end""" 105 106 def __init__(self, device, tracking_id): 107 self.device = device 108 self.tracking_id = tracking_id 109 self.points = [] 110 111 self.is_active = True 112 113 self.is_down = False 114 self.was_down = False 115 self.is_palm = False 116 self.was_palm = False 117 self.is_thumb = False 118 self.was_thumb = False 119 120 self.prange = Range() 121 122 def append(self, touch): 123 """Add a Touch to the sequence""" 124 self.points.append(touch) 125 self.prange.update(touch.pressure) 126 127 if touch.pressure < self.device.up: 128 self.is_down = False 129 elif touch.pressure > self.device.down: 130 self.is_down = True 131 self.was_down = True 132 133 self.is_palm = touch.pressure > self.device.palm 134 if self.is_palm: 135 self.was_palm = True 136 137 self.is_thumb = touch.pressure > self.device.thumb 138 if self.is_thumb: 139 self.was_thumb = True 140 141 def finalize(self): 142 """Mark the TouchSequence as complete (finger is up)""" 143 self.is_active = False 144 145 def avg(self): 146 """Average pressure value of this sequence""" 147 return int(sum([p.pressure for p in self.points]) / len(self.points)) 148 149 def median(self): 150 """Median pressure value of this sequence""" 151 ps = sorted([p.pressure for p in self.points]) 152 idx = int(len(self.points) / 2) 153 return ps[idx] 154 155 def __str__(self): 156 return self._str_state() if self.is_active else self._str_summary() 157 158 def _str_summary(self): 159 if not self.points: 160 return fmt.values([self.tracking_id, False, False, False, False, 161 'No pressure values recorded']) 162 163 s = fmt.values([self.tracking_id, self.was_down, True, self.was_palm, 164 self.was_thumb, self.prange.min, self.prange.max, 0, 165 self.avg(), self.median()]) 166 167 return s 168 169 def _str_state(self): 170 s = fmt.values([self.tracking_id, self.is_down, not self.is_down, 171 self.is_palm, self.is_thumb, self.prange.min, 172 self.prange.max, self.points[-1].pressure]) 173 return s 174 175 176class InvalidDeviceError(Exception): 177 pass 178 179 180class Device(libevdev.Device): 181 def __init__(self, path): 182 if path is None: 183 self.path = self.find_touchpad_device() 184 else: 185 self.path = path 186 187 fd = open(self.path, 'rb') 188 super().__init__(fd) 189 190 print("Using {}: {}\n".format(self.name, self.path)) 191 192 self.has_mt_pressure = True 193 absinfo = self.absinfo[libevdev.EV_ABS.ABS_MT_PRESSURE] 194 if absinfo is None: 195 absinfo = self.absinfo[libevdev.EV_ABS.ABS_PRESSURE] 196 self.has_mt_pressure = False 197 if absinfo is None: 198 raise InvalidDeviceError("Device does not have ABS_PRESSURE or ABS_MT_PRESSURE") 199 200 prange = absinfo.maximum - absinfo.minimum 201 202 # libinput defaults 203 self.down = int(absinfo.minimum + 0.12 * prange) 204 self.up = int(absinfo.minimum + 0.10 * prange) 205 self.palm = 130 # the libinput default 206 self.thumb = absinfo.maximum 207 208 self._init_thresholds_from_quirks() 209 self.sequences = [] 210 211 def find_touchpad_device(self): 212 context = pyudev.Context() 213 for device in context.list_devices(subsystem='input'): 214 if not device.get('ID_INPUT_TOUCHPAD', 0): 215 continue 216 217 if not device.device_node or \ 218 not device.device_node.startswith('/dev/input/event'): 219 continue 220 221 return device.device_node 222 print("Unable to find a touchpad device.", file=sys.stderr) 223 sys.exit(1) 224 225 def _init_thresholds_from_quirks(self): 226 command = ['libinput', 'quirks', 'list', self.path] 227 cmd = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 228 if cmd.returncode != 0: 229 print("Error querying quirks: {}".format(cmd.stderr.decode('utf-8')), file=sys.stderr) 230 return 231 232 stdout = cmd.stdout.decode('utf-8') 233 quirks = [q.split('=') for q in stdout.split('\n')] 234 235 for q in quirks: 236 if q[0] == 'AttrPalmPressureThreshold': 237 self.palm = int(q[1]) 238 elif q[0] == 'AttrPressureRange': 239 self.down, self.up = colon_tuple(q[1]) 240 elif q[0] == 'AttrThumbPressureThreshold': 241 self.thumb = int(q[1]) 242 243 def start_new_sequence(self, tracking_id): 244 self.sequences.append(TouchSequence(self, tracking_id)) 245 246 def current_sequence(self): 247 return self.sequences[-1] 248 249 250def handle_key(device, event): 251 tapcodes = [ 252 libevdev.EV_KEY.BTN_TOOL_DOUBLETAP, 253 libevdev.EV_KEY.BTN_TOOL_TRIPLETAP, 254 libevdev.EV_KEY.BTN_TOOL_QUADTAP, 255 libevdev.EV_KEY.BTN_TOOL_QUINTTAP 256 ] 257 if event.code in tapcodes and event.value > 0: 258 print('\r\033[2KThis tool cannot handle multiple fingers, ' 259 'output will be invalid') 260 261 262def handle_abs(device, event): 263 if event.matches(libevdev.EV_ABS.ABS_MT_TRACKING_ID): 264 if event.value > -1: 265 device.start_new_sequence(event.value) 266 else: 267 try: 268 s = device.current_sequence() 269 s.finalize() 270 print("\r\033[2K{}".format(s)) 271 except IndexError: 272 # If the finger was down at startup 273 pass 274 elif (event.matches(libevdev.EV_ABS.ABS_MT_PRESSURE) or 275 (event.matches(libevdev.EV_ABS.ABS_PRESSURE) and not device.has_mt_pressure)): 276 try: 277 s = device.current_sequence() 278 s.append(Touch(pressure=event.value)) 279 print("\r\033[2K{}".format(s), end="") 280 except IndexError: 281 # If the finger was down at startup 282 pass 283 284 285def handle_event(device, event): 286 if event.matches(libevdev.EV_ABS): 287 handle_abs(device, event) 288 elif event.matches(libevdev.EV_KEY): 289 handle_key(device, event) 290 291 292def loop(device): 293 print('This is an interactive tool') 294 print() 295 print("Place a single finger on the touchpad to measure pressure values.") 296 print('Check that:') 297 print('- touches subjectively perceived as down are tagged as down') 298 print('- touches with a thumb are tagged as thumb') 299 print('- touches with a palm are tagged as palm') 300 print() 301 print('If the touch states do not match the interaction, re-run') 302 print('with --touch-thresholds=down:up using observed pressure values.') 303 print('See --help for more options.') 304 print() 305 print("Press Ctrl+C to exit") 306 print() 307 308 headers = fmt.headers(['Touch', 'down', 'up', 'palm', 'thumb', 'min', 'max', 'p', 'avg', 'median']) 309 print(fmt.separator()) 310 print(fmt.values(['Thresh', device.down, device.up, device.palm, device.thumb])) 311 print(fmt.separator()) 312 print(headers) 313 print(fmt.separator()) 314 315 while True: 316 for event in device.events(): 317 handle_event(device, event) 318 319 320def colon_tuple(string): 321 try: 322 ts = string.split(':') 323 t = tuple([int(x) for x in ts]) 324 if len(t) == 2 and t[0] >= t[1]: 325 return t 326 except: # noqa 327 pass 328 329 msg = "{} is not in format N:M (N >= M)".format(string) 330 raise argparse.ArgumentTypeError(msg) 331 332 333def main(args): 334 parser = argparse.ArgumentParser( 335 description="Measure touchpad pressure values" 336 ) 337 parser.add_argument( 338 'path', metavar='/dev/input/event0', nargs='?', type=str, 339 help='Path to device (optional)' 340 ) 341 parser.add_argument( 342 '--touch-thresholds', metavar='down:up', type=colon_tuple, 343 help='Thresholds when a touch is logically down or up' 344 ) 345 parser.add_argument( 346 '--palm-threshold', metavar='t', type=int, 347 help='Threshold when a touch is a palm' 348 ) 349 parser.add_argument( 350 '--thumb-threshold', metavar='t', type=int, 351 help='Threshold when a touch is a thumb' 352 ) 353 args = parser.parse_args() 354 355 try: 356 device = Device(args.path) 357 358 if args.touch_thresholds is not None: 359 device.down, device.up = args.touch_thresholds 360 361 if args.palm_threshold is not None: 362 device.palm = args.palm_threshold 363 364 if args.thumb_threshold is not None: 365 device.thumb = args.thumb_threshold 366 367 loop(device) 368 except KeyboardInterrupt: 369 print('\r\033[2K{}'.format(fmt.separator())) 370 print() 371 372 except (PermissionError, OSError): 373 print("Error: failed to open device") 374 except InvalidDeviceError as e: 375 print("This device does not have the capabilities for pressure-based touch detection.") 376 print("Details: {}".format(e)) 377 378 379if __name__ == "__main__": 380 main(sys.argv) 381