libinput-measure-touchpad-pressure.py 14 KB

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