libinput-analyze-per-slot-delta.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. #!/usr/bin/env python3
  2. # vim: set expandtab shiftwidth=4:
  3. # -*- Mode: python; coding: utf-8; indent-tabs-mode: nil -*- */
  4. #
  5. # Copyright © 2018 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. #
  27. # Measures the relative motion between touch events (based on slots)
  28. #
  29. # Input is a libinput record yaml file
  30. import argparse
  31. import math
  32. import sys
  33. from dataclasses import dataclass, field, replace
  34. from enum import Enum
  35. import libevdev
  36. import yaml
  37. COLOR_RESET = "\x1b[0m"
  38. COLOR_RED = "\x1b[6;31m"
  39. COLOR_BLUE = "\x1b[6;34m"
  40. COLOR_GREEN = "\x1b[6;32m"
  41. class SlotFormatter:
  42. def __init__(
  43. self,
  44. is_absolute=False,
  45. resolution=None,
  46. threshold=None,
  47. ignore_below=None,
  48. show_distance=False,
  49. pressure_thresholds=(0, 0),
  50. ):
  51. self.threshold = threshold
  52. self.ignore_below = ignore_below
  53. self.resolution = resolution
  54. self.is_absolute = is_absolute
  55. self.show_distance = show_distance
  56. self.pressure_thresholds = pressure_thresholds
  57. self.slots = []
  58. self.have_data = False
  59. self.filtered = False
  60. self.width = 35 if show_distance else 16
  61. def __str__(self):
  62. return " | ".join(self.slots)
  63. def format_slot(self, slot):
  64. if slot.state == SlotState.BEGIN:
  65. self.slots.append("+++++++".center(self.width))
  66. self.have_data = True
  67. elif slot.state == SlotState.END:
  68. self.slots.append("-------".center(self.width))
  69. self.have_data = True
  70. elif slot.state == SlotState.NONE:
  71. self.slots.append(("*" * (self.width - 2)).center(self.width))
  72. elif not slot.dirty:
  73. self.slots.append(" ".center(self.width))
  74. else:
  75. if self.resolution is not None:
  76. delta = Point(
  77. slot.delta.x / self.resolution[0],
  78. slot.delta.y / self.resolution[1],
  79. )
  80. distx = abs(slot.position.x - slot.origin.x)
  81. disty = abs(slot.position.y - slot.origin.y)
  82. distance = Point(
  83. distx / self.resolution[0],
  84. disty / self.resolution[1],
  85. )
  86. else:
  87. delta = Point(slot.delta.x, slot.delta.y)
  88. distance = Point(
  89. abs(slot.position.x - slot.origin.x),
  90. abs(slot.position.y - slot.origin.y),
  91. )
  92. if delta.x != 0 and delta.y != 0:
  93. t = math.atan2(delta.x, delta.y)
  94. t += math.pi # in [0, 2pi] range now
  95. if t == 0:
  96. t = 0.01
  97. else:
  98. t = t * 180.0 / math.pi
  99. directions = ["↖↑", "↖←", "↙←", "↙↓", "↓↘", "→↘", "→↗", "↑↗"]
  100. direction = directions[int(t / 45)]
  101. elif delta.y == 0:
  102. if delta.x < 0:
  103. direction = "←←"
  104. else:
  105. direction = "→→"
  106. else:
  107. if delta.y < 0:
  108. direction = "↑↑"
  109. else:
  110. direction = "↓↓"
  111. color = COLOR_RESET
  112. reset = COLOR_RESET
  113. if not self.is_absolute:
  114. if (
  115. self.pressure_thresholds[1] > 0
  116. and slot.pressure > self.pressure_thresholds[1]
  117. ):
  118. color = COLOR_GREEN
  119. reset = COLOR_RESET
  120. elif (
  121. self.pressure_thresholds[0] > 0
  122. and slot.pressure > self.pressure_thresholds[0]
  123. ):
  124. color = COLOR_BLUE
  125. reset = COLOR_RESET
  126. if self.ignore_below is not None or self.threshold is not None:
  127. dist = math.hypot(delta.x, delta.y)
  128. if self.ignore_below is not None and dist < self.ignore_below:
  129. self.slots.append(" ".center(self.width))
  130. self.filtered = True
  131. return
  132. if self.threshold is not None and dist >= self.threshold:
  133. color = COLOR_RED
  134. reset = COLOR_RESET
  135. if isinstance(delta.x, int) and isinstance(delta.y, int):
  136. coords = f"{delta.x:+4d}/{delta.y:+4d}"
  137. else:
  138. coords = f"{delta.x:+3.2f}/{delta.y:+03.2f}"
  139. if self.show_distance:
  140. hypot = math.hypot(distance.x, distance.y)
  141. distance = (
  142. f"dist: ({distance.x:3.1f}/{distance.y:3.1f}, {hypot:3.1f})"
  143. )
  144. else:
  145. distance = ""
  146. components = [
  147. f"{direction}",
  148. f"{color}",
  149. coords,
  150. distance,
  151. f"{reset}",
  152. ]
  153. string = " ".join(c for c in components if c)
  154. else:
  155. x, y = slot.position.x, slot.position.y
  156. string = f"{direction} {color}{x:4d}/{y:4d}{reset}"
  157. self.have_data = True
  158. self.slots.append(string.ljust(self.width + len(color) + len(reset)))
  159. class SlotState(Enum):
  160. NONE = 0
  161. BEGIN = 1
  162. UPDATE = 2
  163. END = 3
  164. @dataclass
  165. class Point:
  166. x: float = 0.0
  167. y: float = 0.0
  168. @dataclass
  169. class Slot:
  170. index: int
  171. state: SlotState = SlotState.NONE
  172. position: Point = field(default_factory=Point)
  173. delta: Point = field(default_factory=Point)
  174. origin: Point = field(default_factory=Point)
  175. pressure: int = 0
  176. used: bool = False
  177. dirty: bool = False
  178. def main(argv):
  179. global COLOR_RESET
  180. global COLOR_RED
  181. global COLOR_BLUE
  182. global COLOR_GREEN
  183. slots = []
  184. xres, yres = 1, 1
  185. parser = argparse.ArgumentParser(
  186. description="Measure delta between event frames for each slot"
  187. )
  188. parser.add_argument(
  189. "--use-mm", action="store_true", help="Use mm instead of device deltas"
  190. )
  191. parser.add_argument(
  192. "--show-distance",
  193. action="store_true",
  194. help="Show the absolute distance relative to the first position",
  195. )
  196. parser.add_argument(
  197. "--use-st",
  198. action="store_true",
  199. help="Use ABS_X/ABS_Y instead of ABS_MT_POSITION_X/Y",
  200. )
  201. parser.add_argument(
  202. "--use-absolute",
  203. action="store_true",
  204. help="Use absolute coordinates, not deltas",
  205. )
  206. parser.add_argument(
  207. "path", metavar="recording", nargs=1, help="Path to libinput-record YAML file"
  208. )
  209. parser.add_argument(
  210. "--threshold",
  211. type=float,
  212. default=None,
  213. help="Mark any delta above this threshold",
  214. )
  215. parser.add_argument(
  216. "--ignore-below",
  217. type=float,
  218. default=None,
  219. help="Ignore any delta below this threshold",
  220. )
  221. parser.add_argument(
  222. "--pressure-min",
  223. type=int,
  224. default=0,
  225. help="Highlight touches above this pressure minimum",
  226. )
  227. parser.add_argument(
  228. "--pressure-max",
  229. type=int,
  230. default=0,
  231. help="Highlight touches below this pressure maximum",
  232. )
  233. args = parser.parse_args()
  234. if not sys.stdout.isatty():
  235. COLOR_RESET = ""
  236. COLOR_RED = ""
  237. COLOR_GREEN = ""
  238. COLOR_BLUE = ""
  239. with open(args.path[0]) as f:
  240. yml = yaml.safe_load(f)
  241. device = yml["devices"][0]
  242. absinfo = device["evdev"]["absinfo"]
  243. try:
  244. nslots = absinfo[libevdev.EV_ABS.ABS_MT_SLOT.value][1] + 1
  245. except KeyError:
  246. args.use_st = True
  247. if args.use_st:
  248. nslots = 1
  249. slots = [Slot(i) for i in range(nslots)]
  250. slots[0].used = True
  251. if args.use_mm:
  252. xres = 1.0 * absinfo[libevdev.EV_ABS.ABS_X.value][4]
  253. yres = 1.0 * absinfo[libevdev.EV_ABS.ABS_Y.value][4]
  254. if not xres or not yres:
  255. print("Error: device doesn't have a resolution, cannot use mm")
  256. sys.exit(1)
  257. if args.use_st:
  258. print("Warning: slot coordinates on FINGER/DOUBLETAP change may be incorrect")
  259. slots[0].used = True
  260. slot = 0
  261. last_time = None
  262. tool_bits = {
  263. libevdev.EV_KEY.BTN_TOUCH: 0,
  264. libevdev.EV_KEY.BTN_TOOL_DOUBLETAP: 0,
  265. libevdev.EV_KEY.BTN_TOOL_TRIPLETAP: 0,
  266. libevdev.EV_KEY.BTN_TOOL_QUADTAP: 0,
  267. libevdev.EV_KEY.BTN_TOOL_QUINTTAP: 0,
  268. }
  269. btn_state = {
  270. libevdev.EV_KEY.BTN_LEFT: 0,
  271. libevdev.EV_KEY.BTN_MIDDLE: 0,
  272. libevdev.EV_KEY.BTN_RIGHT: 0,
  273. }
  274. nskipped_lines = 0
  275. for event in device["events"]:
  276. for evdev in event["evdev"]:
  277. s = slots[slot]
  278. e = libevdev.InputEvent(
  279. code=libevdev.evbit(evdev[2], evdev[3]),
  280. value=evdev[4],
  281. sec=evdev[0],
  282. usec=evdev[1],
  283. )
  284. if e.code in tool_bits:
  285. tool_bits[e.code] = e.value
  286. if e.code in btn_state:
  287. btn_state[e.code] = e.value
  288. if args.use_st:
  289. # Note: this relies on the EV_KEY events to come in before the
  290. # x/y events, otherwise the last/first event in each slot will
  291. # be wrong.
  292. if (
  293. e.code == libevdev.EV_KEY.BTN_TOOL_FINGER
  294. or e.code == libevdev.EV_KEY.BTN_TOOL_PEN
  295. ):
  296. slot = 0
  297. s = slots[slot]
  298. s.dirty = True
  299. if e.value:
  300. s.state = SlotState.BEGIN
  301. else:
  302. s.state = SlotState.END
  303. elif e.code == libevdev.EV_KEY.BTN_TOOL_DOUBLETAP:
  304. if len(slots) > 1:
  305. slot = 1
  306. s = slots[slot]
  307. s.dirty = True
  308. if e.value:
  309. s.state = SlotState.BEGIN
  310. else:
  311. s.state = SlotState.END
  312. elif e.code == libevdev.EV_ABS.ABS_PRESSURE:
  313. s.pressure = e.value
  314. else:
  315. if e.code == libevdev.EV_ABS.ABS_MT_SLOT:
  316. slot = e.value
  317. s = slots[slot]
  318. s.dirty = True
  319. # bcm5974 cycles through slot numbers, so let's say all below
  320. # our current slot number was used
  321. for sl in slots[: slot + 1]:
  322. sl.used = True
  323. elif e.code == libevdev.EV_ABS.ABS_MT_TRACKING_ID:
  324. if e.value == -1:
  325. s.state = SlotState.END
  326. else:
  327. s.state = SlotState.BEGIN
  328. s.delta.x, s.delta.y = 0, 0
  329. s.dirty = True
  330. elif e.code == libevdev.EV_ABS.ABS_MT_PRESSURE:
  331. s.pressure = e.value
  332. if args.use_st:
  333. axes = [libevdev.EV_ABS.ABS_X, libevdev.EV_ABS.ABS_Y]
  334. else:
  335. axes = [
  336. libevdev.EV_ABS.ABS_MT_POSITION_X,
  337. libevdev.EV_ABS.ABS_MT_POSITION_Y,
  338. ]
  339. if e.code in axes:
  340. s.dirty = True
  341. # If recording started after touch down
  342. if s.state == SlotState.NONE:
  343. s.state = SlotState.BEGIN
  344. s.delta = Point(0, 0)
  345. if e.code in [
  346. libevdev.EV_ABS.ABS_X,
  347. libevdev.EV_ABS.ABS_MT_POSITION_X,
  348. ]:
  349. if s.state == SlotState.UPDATE:
  350. s.delta.x = e.value - s.position.x
  351. s.position.x = e.value
  352. elif e.code in [
  353. libevdev.EV_ABS.ABS_Y,
  354. libevdev.EV_ABS.ABS_MT_POSITION_Y,
  355. ]:
  356. if s.state == SlotState.UPDATE:
  357. s.delta.y = e.value - s.position.y
  358. s.position.y = e.value
  359. else:
  360. assert False, f"Invalid axis {e.code}"
  361. if e.code == libevdev.EV_SYN.SYN_REPORT:
  362. if last_time is None:
  363. last_time = e.sec * 1000000 + e.usec
  364. tdelta = 0
  365. else:
  366. t = e.sec * 1000000 + e.usec
  367. tdelta = int((t - last_time) / 1000) # ms
  368. last_time = t
  369. tools = [
  370. (libevdev.EV_KEY.BTN_TOOL_QUINTTAP, "QIN"),
  371. (libevdev.EV_KEY.BTN_TOOL_QUADTAP, "QAD"),
  372. (libevdev.EV_KEY.BTN_TOOL_TRIPLETAP, "TRI"),
  373. (libevdev.EV_KEY.BTN_TOOL_DOUBLETAP, "DBL"),
  374. (libevdev.EV_KEY.BTN_TOUCH, "TOU"),
  375. ]
  376. for bit, string in tools:
  377. if tool_bits[bit]:
  378. tool_state = string
  379. break
  380. else:
  381. tool_state = " "
  382. buttons = [
  383. (libevdev.EV_KEY.BTN_LEFT, "L"),
  384. (libevdev.EV_KEY.BTN_MIDDLE, "M"),
  385. (libevdev.EV_KEY.BTN_RIGHT, "R"),
  386. ]
  387. button_state = (
  388. "".join([string for bit, string in buttons if btn_state[bit]])
  389. or "."
  390. )
  391. fmt = SlotFormatter(
  392. is_absolute=args.use_absolute,
  393. resolution=(xres, yres) if args.use_mm else None,
  394. threshold=args.threshold,
  395. ignore_below=args.ignore_below,
  396. show_distance=args.show_distance,
  397. pressure_thresholds=(args.pressure_min, args.pressure_max),
  398. )
  399. for sl in [s for s in slots if s.used]:
  400. fmt.format_slot(sl)
  401. sl.dirty = False
  402. sl.delta.x, sl.delta.y = 0, 0
  403. if sl.state == SlotState.BEGIN:
  404. sl.origin = replace(sl.position)
  405. sl.state = SlotState.UPDATE
  406. elif sl.state == SlotState.END:
  407. sl.state = SlotState.NONE
  408. if fmt.have_data:
  409. if nskipped_lines > 0:
  410. print()
  411. nskipped_lines = 0
  412. print(
  413. f"{e.sec:2d}.{e.usec:06d} {tdelta:+5d}ms {tool_state} {button_state} {fmt}"
  414. )
  415. elif fmt.filtered:
  416. nskipped_lines += 1
  417. print(
  418. "\r",
  419. " " * 21,
  420. f"... {nskipped_lines} below threshold",
  421. flush=True,
  422. end="",
  423. )
  424. if __name__ == "__main__":
  425. try:
  426. main(sys.argv)
  427. except KeyboardInterrupt:
  428. pass