libinput-analyze-buttons.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. #!/usr/bin/env python3
  2. # vim: set expandtab shiftwidth=4:
  3. # -*- Mode: python; coding: utf-8; indent-tabs-mode: nil -*- */
  4. #
  5. # Copyright © 2024 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. # Prints the data from a libinput recording in a table format to ease
  27. # debugging.
  28. #
  29. # Input is a libinput record yaml file
  30. import argparse
  31. import os
  32. import sys
  33. from dataclasses import dataclass
  34. import libevdev
  35. import yaml
  36. COLOR_RESET = "\x1b[0m"
  37. COLOR_RED = "\x1b[6;31m"
  38. def micros(e: libevdev.InputEvent):
  39. return e.usec + e.sec * 1_000_000
  40. @dataclass
  41. class Timestamp:
  42. sec: int
  43. usec: int
  44. @property
  45. def micros(self) -> int:
  46. return self.usec + self.sec * 1_000_000
  47. @dataclass
  48. class ButtonFrame:
  49. delta_ms: int # delta time to last button (not evdev!) frame
  50. evdev_delta_ms: int # delta time to last evdev frame
  51. events: list[libevdev.InputEvent] # BTN_ events only
  52. @property
  53. def timestamp(self) -> Timestamp:
  54. e = self.events[0]
  55. return Timestamp(e.sec, e.usec)
  56. def value(self, code: libevdev.EventCode) -> bool | None:
  57. for e in self.events:
  58. if e.matches(code):
  59. return e.value
  60. return None
  61. def values(self, codes: list[libevdev.EventCode]) -> list[bool | None]:
  62. return [self.value(code) for code in codes]
  63. def frames(events):
  64. last_timestamp = None
  65. current_frame = None
  66. last_frame = None
  67. for e in events:
  68. if last_timestamp is None:
  69. last_timestamp = micros(e)
  70. if e.type == libevdev.EV_SYN:
  71. last_timestamp = micros(e)
  72. if current_frame is not None:
  73. yield current_frame
  74. last_frame = current_frame
  75. current_frame = None
  76. elif e.type == libevdev.EV_KEY:
  77. if e.code.name.startswith("BTN_") and not e.code.name.startswith(
  78. "BTN_TOOL_"
  79. ):
  80. timestamp = micros(e)
  81. evdev_delta = (timestamp - last_timestamp) // 1000
  82. if last_frame is not None:
  83. delta = (timestamp - last_frame.timestamp.micros) // 1000
  84. else:
  85. delta = 0
  86. if current_frame is None:
  87. current_frame = ButtonFrame(
  88. delta_ms=delta, evdev_delta_ms=evdev_delta, events=[e]
  89. )
  90. else:
  91. current_frame.events.append(e)
  92. def main(argv):
  93. parser = argparse.ArgumentParser(description="Display button events in a recording")
  94. parser.add_argument(
  95. "--threshold",
  96. type=int,
  97. default=25,
  98. help="Mark any time delta above this threshold (in ms)",
  99. )
  100. parser.add_argument(
  101. "path", metavar="recording", nargs=1, help="Path to libinput-record YAML file"
  102. )
  103. args = parser.parse_args()
  104. isatty = os.isatty(sys.stdout.fileno())
  105. if not isatty:
  106. global COLOR_RESET
  107. global COLOR_RED
  108. COLOR_RESET = ""
  109. COLOR_RED = ""
  110. with open(args.path[0]) as f:
  111. yml = yaml.safe_load(f)
  112. if yml["ndevices"] > 1:
  113. print(f"WARNING: Using only first {yml['ndevices']} devices in recording")
  114. device = yml["devices"][0]
  115. if not device["events"]:
  116. print("No events found in recording")
  117. sys.exit(1)
  118. def events():
  119. """
  120. Yields the next event in the recording
  121. """
  122. for event in device["events"]:
  123. for evdev in event.get("evdev", []):
  124. yield libevdev.InputEvent(
  125. code=libevdev.evbit(evdev[2], evdev[3]),
  126. value=evdev[4],
  127. sec=evdev[0],
  128. usec=evdev[1],
  129. )
  130. # These are the buttons we possibly care about, but we filter to the ones
  131. # found on this device anyway
  132. buttons = [
  133. libevdev.EV_KEY.BTN_LEFT,
  134. libevdev.EV_KEY.BTN_MIDDLE,
  135. libevdev.EV_KEY.BTN_RIGHT,
  136. libevdev.EV_KEY.BTN_SIDE,
  137. libevdev.EV_KEY.BTN_EXTRA,
  138. libevdev.EV_KEY.BTN_FORWARD,
  139. libevdev.EV_KEY.BTN_BACK,
  140. libevdev.EV_KEY.BTN_TASK,
  141. libevdev.EV_KEY.BTN_TOUCH,
  142. libevdev.EV_KEY.BTN_STYLUS,
  143. libevdev.EV_KEY.BTN_STYLUS2,
  144. libevdev.EV_KEY.BTN_STYLUS3,
  145. libevdev.EV_KEY.BTN_0,
  146. libevdev.EV_KEY.BTN_1,
  147. libevdev.EV_KEY.BTN_2,
  148. libevdev.EV_KEY.BTN_3,
  149. libevdev.EV_KEY.BTN_4,
  150. libevdev.EV_KEY.BTN_5,
  151. libevdev.EV_KEY.BTN_6,
  152. libevdev.EV_KEY.BTN_7,
  153. libevdev.EV_KEY.BTN_8,
  154. libevdev.EV_KEY.BTN_9,
  155. ]
  156. def filter_buttons(buttons):
  157. return filter(
  158. lambda c: c in buttons,
  159. (libevdev.evbit("EV_KEY", c) for c in device["evdev"]["codes"][1]),
  160. )
  161. buttons = list(filter_buttons(buttons))
  162. # all BTN_STYLUS will have a header of S - meh
  163. btn_headers = " │ ".join(b.name[4] for b in buttons)
  164. print(f"{'Timestamp':^13s} │ {'Delta':^8s} │ {btn_headers}")
  165. last_btn_vals = [None] * len(buttons)
  166. def btnchar(b, last):
  167. if b == 1:
  168. return "┬"
  169. if b == 0:
  170. return "┴"
  171. return "│" if last else " "
  172. for frame in frames(events()):
  173. ts = frame.timestamp
  174. if frame.timestamp.micros > 0 and frame.delta_ms < args.threshold:
  175. color = COLOR_RED
  176. else:
  177. color = ""
  178. btn_vals = frame.values(buttons)
  179. btn_strs = " │ ".join(
  180. [btnchar(b, last) for b, last in zip(btn_vals, last_btn_vals)]
  181. )
  182. last_btn_vals = [
  183. b if b is not None else last for b, last in zip(btn_vals, last_btn_vals)
  184. ]
  185. print(
  186. f"{color}{ts.sec:6d}.{ts.usec:06d} │ {frame.delta_ms:6d}ms │ {btn_strs}{COLOR_RESET}"
  187. )
  188. if __name__ == "__main__":
  189. try:
  190. main(sys.argv)
  191. except BrokenPipeError:
  192. pass