#!/usr/bin/env python3 # vim: set expandtab shiftwidth=4: # -*- Mode: python; coding: utf-8; indent-tabs-mode: nil -*- */ # # Copyright © 2017 Red Hat, Inc. # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), # to deal in the Software without restriction, including without limitation # the rights to use, copy, modify, merge, publish, distribute, sublicense, # and/or sell copies of the Software, and to permit persons to whom the # Software is furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice (including the next # paragraph) shall be included in all copies or substantial portions of the # Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. # import sys import argparse try: import evdev import evdev.ecodes import textwrap import pyudev except ModuleNotFoundError as e: print('Error: {}'.format(e), file=sys.stderr) print('One or more python modules are missing. Please install those ' 'modules and re-run this tool.') sys.exit(1) print_dest = sys.stdout def error(msg, **kwargs): print(msg, **kwargs, file=sys.stderr) def msg(msg, **kwargs): print(msg, **kwargs, file=print_dest, flush=True) def tv2us(sec, usec): return sec * 1000000 + usec def us2ms(us): return int(us/1000) class Touch(object): def __init__(self, down): self._down = down self._up = down @property def up(self): return us2ms(self._up) @up.setter def up(self, up): assert(up > self.down) self._up = up @property def down(self): return us2ms(self._down) @property def tdelta(self): return self.up - self.down class InvalidDeviceError(Exception): pass class Device(object): def __init__(self, path): if path is None: self.path = self._find_touch_device() else: self.path = path self.device = evdev.InputDevice(self.path) print("Using {}: {}\n".format(self.device.name, self.path)) # capabilities returns a dict with the EV_* codes as key, # each of which is a list of tuples of (code, AbsInfo) # # Get the abs list first (or empty list if missing), # then extract the pressure absinfo from that codes = self.device.capabilities(absinfo=True).get( evdev.ecodes.EV_KEY, [] ) if evdev.ecodes.BTN_TOUCH not in codes: raise InvalidDeviceError("device does not have BTN_TOUCH") self.touches = [] def _find_touch_device(self): context = pyudev.Context() device_node = None for device in context.list_devices(subsystem='input'): if (not device.device_node or not device.device_node.startswith('/dev/input/event')): continue # pick the touchpad by default, fallback to the first # touchscreen only when there is no touchpad if device.get('ID_INPUT_TOUCHPAD', 0): device_node = device.device_node break if device.get('ID_INPUT_TOUCHSCREEN', 0) and device_node is None: device_node = device.device_node if device_node is not None: return device_node error("Unable to find a touch device.") sys.exit(1) def handle_btn_touch(self, event): if event.value != 0: t = Touch(tv2us(event.sec, event.usec)) self.touches.append(t) else: self.touches[-1].up = tv2us(event.sec, event.usec) msg("\rTouch sequences detected: {}".format(len(self.touches)), end='') def handle_key(self, event): tapcodes = [evdev.ecodes.BTN_TOOL_DOUBLETAP, evdev.ecodes.BTN_TOOL_TRIPLETAP, evdev.ecodes.BTN_TOOL_QUADTAP, evdev.ecodes.BTN_TOOL_QUINTTAP] if event.code in tapcodes and event.value > 0: error("\rThis tool cannot handle multiple fingers, " "output will be invalid") return if event.code == evdev.ecodes.BTN_TOUCH: self.handle_btn_touch(event) def handle_syn(self, event): if self.touch.dirty: self.current_sequence().append(self.touch) self.touch = Touch(major=self.touch.major, minor=self.touch.minor, orientation=self.touch.orientation) def handle_event(self, event): if event.type == evdev.ecodes.EV_KEY: self.handle_key(event) def read_events(self): for event in self.device.read_loop(): self.handle_event(event) def print_summary(self): deltas = sorted(t.tdelta for t in self.touches) dmax = max(deltas) dmin = min(deltas) l = len(deltas) davg = sum(deltas)/l dmedian = deltas[int(l/2)] d95pc = deltas[int(l * 0.95)] d90pc = deltas[int(l * 0.90)] print("Time: ") print(" Max delta: {}ms".format(int(dmax))) print(" Min delta: {}ms".format(int(dmin))) print(" Average delta: {}ms".format(int(davg))) print(" Median delta: {}ms".format(int(dmedian))) print(" 90th percentile: {}ms".format(int(d90pc))) print(" 95th percentile: {}ms".format(int(d95pc))) def print_dat(self): print("# libinput-measure-touchpad-tap") print(textwrap.dedent('''\ # File contents: # This file contains multiple prints of the data in # different sort order. Row number is index of touch # point within each group. Comparing data across groups # will result in invalid analysis. # Columns (1-indexed): # Group 1, sorted by time of occurence # 1: touch down time in ms, offset by first event # 2: touch up time in ms, offset by first event # 3: time delta in ms); # Group 2, sorted by touch down-up delta time (ascending) # 4: touch down time in ms, offset by first event # 5: touch up time in ms, offset by first event # 6: time delta in ms ''')) deltas = [t for t in self.touches] deltas_sorted = sorted(deltas, key=lambda t: t.tdelta) offset = deltas[0].down for t1, t2 in zip(deltas, deltas_sorted): print(t1.down - offset, t1.up - offset, t1.tdelta, t2.down - offset, t2.up - offset, t2.tdelta) def print(self, format): if not self.touches: error("No tap data available") return if format == 'summary': self.print_summary() elif format == 'dat': self.print_dat() def main(args): parser = argparse.ArgumentParser( description="Measure tap-to-click properties of devices" ) parser.add_argument('path', metavar='/dev/input/event0', nargs='?', type=str, help='Path to device (optional)') parser.add_argument('--format', metavar='format', choices=['summary', 'dat'], default='summary', help='data format to print ("summary" or "dat")') args = parser.parse_args() if not sys.stdout.isatty(): global print_dest print_dest = sys.stderr try: device = Device(args.path) error("Ready for recording data.\n" "Tap the touchpad multiple times with a single finger only.\n" "For useful data we recommend at least 20 taps.\n" "Ctrl+C to exit") device.read_events() except KeyboardInterrupt: msg('') device.print(args.format) except (PermissionError, OSError) as e: error("Error: failed to open device. {}".format(e)) except InvalidDeviceError as e: error("Error: {}".format(e)) if __name__ == "__main__": main(sys.argv)