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