libinput-measure-touchpad-tap.py 8.4 KB

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