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 argparse 29 30try: 31 import libevdev 32 import textwrap 33 import pyudev 34except ModuleNotFoundError as e: 35 print("Error: {}".format(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 42print_dest = sys.stdout 43 44 45def error(msg, **kwargs): 46 print(msg, **kwargs, file=sys.stderr) 47 48 49def msg(msg, **kwargs): 50 print(msg, **kwargs, file=print_dest, flush=True) 51 52 53def tv2us(sec, usec): 54 return sec * 1000000 + usec 55 56 57def us2ms(us): 58 return int(us / 1000) 59 60 61class Touch(object): 62 def __init__(self, down): 63 self._down = down 64 self._up = down 65 66 @property 67 def up(self): 68 return us2ms(self._up) 69 70 @up.setter 71 def up(self, up): 72 assert up > self.down 73 self._up = up 74 75 @property 76 def down(self): 77 return us2ms(self._down) 78 79 @property 80 def tdelta(self): 81 return self.up - self.down 82 83 84class InvalidDeviceError(Exception): 85 pass 86 87 88class Device(libevdev.Device): 89 def __init__(self, path): 90 if path is None: 91 self.path = self._find_touch_device() 92 else: 93 self.path = path 94 fd = open(self.path, "rb") 95 super().__init__(fd) 96 97 print("Using {}: {}\n".format(self.name, self.path)) 98 99 if not self.has(libevdev.EV_KEY.BTN_TOUCH): 100 raise InvalidDeviceError("device does not have BTN_TOUCH") 101 102 self.touches = [] 103 104 def _find_touch_device(self): 105 context = pyudev.Context() 106 device_node = None 107 for device in context.list_devices(subsystem="input"): 108 if not device.device_node or not device.device_node.startswith( 109 "/dev/input/event" 110 ): 111 continue 112 113 # pick the touchpad by default, fallback to the first 114 # touchscreen only when there is no touchpad 115 if device.get("ID_INPUT_TOUCHPAD", 0): 116 device_node = device.device_node 117 break 118 119 if device.get("ID_INPUT_TOUCHSCREEN", 0) and device_node is None: 120 device_node = device.device_node 121 122 if device_node is not None: 123 return device_node 124 125 error("Unable to find a touch device.") 126 sys.exit(1) 127 128 def handle_btn_touch(self, event): 129 if event.value != 0: 130 t = Touch(tv2us(event.sec, event.usec)) 131 self.touches.append(t) 132 else: 133 self.touches[-1].up = tv2us(event.sec, event.usec) 134 msg("\rTouch sequences detected: {}".format(len(self.touches)), end="") 135 136 def handle_key(self, event): 137 tapcodes = [ 138 libevdev.EV_KEY.BTN_TOOL_DOUBLETAP, 139 libevdev.EV_KEY.BTN_TOOL_TRIPLETAP, 140 libevdev.EV_KEY.BTN_TOOL_QUADTAP, 141 libevdev.EV_KEY.BTN_TOOL_QUINTTAP, 142 ] 143 if event.code in tapcodes and event.value > 0: 144 error( 145 "\rThis tool cannot handle multiple fingers, " "output will be invalid" 146 ) 147 return 148 149 if event.matches(libevdev.EV_KEY.BTN_TOUCH): 150 self.handle_btn_touch(event) 151 152 def handle_syn(self, event): 153 if self.touch.dirty: 154 self.current_sequence().append(self.touch) 155 self.touch = Touch( 156 major=self.touch.major, 157 minor=self.touch.minor, 158 orientation=self.touch.orientation, 159 ) 160 161 def handle_event(self, event): 162 if event.matches(libevdev.EV_KEY): 163 self.handle_key(event) 164 165 def read_events(self): 166 while True: 167 for event in self.events(): 168 self.handle_event(event) 169 170 def print_summary(self): 171 deltas = sorted(t.tdelta for t in self.touches) 172 173 dmax = max(deltas) 174 dmin = min(deltas) 175 176 ndeltas = len(deltas) 177 178 davg = sum(deltas) / ndeltas 179 dmedian = deltas[int(ndeltas / 2)] 180 d95pc = deltas[int(ndeltas * 0.95)] 181 d90pc = deltas[int(ndeltas * 0.90)] 182 183 print("Time: ") 184 print(" Max delta: {}ms".format(int(dmax))) 185 print(" Min delta: {}ms".format(int(dmin))) 186 print(" Average delta: {}ms".format(int(davg))) 187 print(" Median delta: {}ms".format(int(dmedian))) 188 print(" 90th percentile: {}ms".format(int(d90pc))) 189 print(" 95th percentile: {}ms".format(int(d95pc))) 190 191 def print_dat(self): 192 print("# libinput-measure-touchpad-tap") 193 print( 194 textwrap.dedent( 195 """\ 196 # File contents: 197 # This file contains multiple prints of the data in 198 # different sort order. Row number is index of touch 199 # point within each group. Comparing data across groups 200 # will result in invalid analysis. 201 # Columns (1-indexed): 202 # Group 1, sorted by time of occurrence 203 # 1: touch down time in ms, offset by first event 204 # 2: touch up time in ms, offset by first event 205 # 3: time delta in ms); 206 # Group 2, sorted by touch down-up delta time (ascending) 207 # 4: touch down time in ms, offset by first event 208 # 5: touch up time in ms, offset by first event 209 # 6: time delta in ms 210 """ 211 ) 212 ) 213 214 deltas = [t for t in self.touches] 215 deltas_sorted = sorted(deltas, key=lambda t: t.tdelta) 216 217 offset = deltas[0].down 218 219 for t1, t2 in zip(deltas, deltas_sorted): 220 print( 221 t1.down - offset, 222 t1.up - offset, 223 t1.tdelta, 224 t2.down - offset, 225 t2.up - offset, 226 t2.tdelta, 227 ) 228 229 def print(self, format): 230 if not self.touches: 231 error("No tap data available") 232 return 233 234 if format == "summary": 235 self.print_summary() 236 elif format == "dat": 237 self.print_dat() 238 239 240def main(args): 241 parser = argparse.ArgumentParser( 242 description="Measure tap-to-click properties of devices" 243 ) 244 parser.add_argument( 245 "path", 246 metavar="/dev/input/event0", 247 nargs="?", 248 type=str, 249 help="Path to device (optional)", 250 ) 251 parser.add_argument( 252 "--format", 253 metavar="format", 254 choices=["summary", "dat"], 255 default="summary", 256 help='data format to print ("summary" or "dat")', 257 ) 258 args = parser.parse_args() 259 260 if not sys.stdout.isatty(): 261 global print_dest 262 print_dest = sys.stderr 263 264 try: 265 device = Device(args.path) 266 error( 267 "Ready for recording data.\n" 268 "Tap the touchpad multiple times with a single finger only.\n" 269 "For useful data we recommend at least 20 taps.\n" 270 "Ctrl+C to exit" 271 ) 272 device.read_events() 273 except KeyboardInterrupt: 274 msg("") 275 device.print(args.format) 276 except (PermissionError, OSError) as e: 277 error("Error: failed to open device. {}".format(e)) 278 except InvalidDeviceError as e: 279 error("Error: {}".format(e)) 280 281 282if __name__ == "__main__": 283 main(sys.argv) 284