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