1
0

libinput-measure-touchpad-size.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. #!/usr/bin/env python3
  2. # vim: set expandtab shiftwidth=4:
  3. # -*- Mode: python; coding: utf-8; indent-tabs-mode: nil -*- */
  4. #
  5. # Copyright © 2020 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. import argparse
  26. import sys
  27. try:
  28. import libevdev
  29. import pyudev
  30. except ModuleNotFoundError as e:
  31. print(f"Error: {e!s}", file=sys.stderr)
  32. print(
  33. "One or more python modules are missing. Please install those "
  34. "modules and re-run this tool."
  35. )
  36. sys.exit(1)
  37. class DeviceError(Exception):
  38. pass
  39. class Point:
  40. def __init__(self, x=None, y=None):
  41. self.x = x
  42. self.y = y
  43. class Touchpad:
  44. def __init__(self, evdev):
  45. x = evdev.absinfo[libevdev.EV_ABS.ABS_X]
  46. y = evdev.absinfo[libevdev.EV_ABS.ABS_Y]
  47. if not x or not y:
  48. raise DeviceError("Device does not have an x or axis")
  49. if not x.resolution or not y.resolution:
  50. print("Device does not have resolutions.", file=sys.stderr)
  51. x.resolution = 1
  52. y.resolution = 1
  53. self.xrange = x.maximum - x.minimum
  54. self.yrange = y.maximum - y.minimum
  55. self.width = self.xrange / x.resolution
  56. self.height = self.yrange / y.resolution
  57. self._x = x
  58. self._y = y
  59. # We try to make the touchpad at least look proportional. The
  60. # terminal character space is (guesswork) ca 2.3 times as high as
  61. # wide.
  62. self.columns = 30
  63. self.rows = int(
  64. self.columns
  65. * (self.yrange // y.resolution)
  66. // (self.xrange // x.resolution)
  67. / 2.3
  68. )
  69. self.pos = Point(0, 0)
  70. self.min = Point()
  71. self.max = Point()
  72. @property
  73. def x(self):
  74. return self._x
  75. @property
  76. def y(self):
  77. return self._y
  78. @x.setter
  79. def x(self, x):
  80. self._x.minimum = min(self.x.minimum, x)
  81. self._x.maximum = max(self.x.maximum, x)
  82. self.min.x = min(x, self.min.x or 0xFFFFFFFF)
  83. self.max.x = max(x, self.max.x or -0xFFFFFFFF)
  84. # we calculate the position based on the original range.
  85. # this means on devices with a narrower range than advertised, not
  86. # all corners may be reachable in the touchpad drawing.
  87. self.pos.x = min(0.99, (x - self._x.minimum) / self.xrange)
  88. @y.setter
  89. def y(self, y):
  90. self._y.minimum = min(self.y.minimum, y)
  91. self._y.maximum = max(self.y.maximum, y)
  92. self.min.y = min(y, self.min.y or 0xFFFFFFFF)
  93. self.max.y = max(y, self.max.y or -0xFFFFFFFF)
  94. # we calculate the position based on the original range.
  95. # this means on devices with a narrower range than advertised, not
  96. # all corners may be reachable in the touchpad drawing.
  97. self.pos.y = min(0.99, (y - self._y.minimum) / self.yrange)
  98. def update_from_data(self):
  99. if None in [self.min.x, self.min.y, self.max.x, self.max.y]:
  100. raise DeviceError("Insufficient data to continue")
  101. self._x.minimum = self.min.x
  102. self._x.maximum = self.max.x
  103. self._y.minimum = self.min.y
  104. self._y.maximum = self.max.y
  105. def draw(self):
  106. min_x = self.min.x if self.min.x is not None else 0
  107. max_x = self.max.x if self.max.x is not None else 0
  108. min_y = self.min.y if self.min.y is not None else 0
  109. max_y = self.max.y if self.max.y is not None else 0
  110. print(
  111. f"Detected axis range: x [{min_x:4d}..{max_x:4d}], y [{min_y:4d}..{max_y:4d}]"
  112. )
  113. print()
  114. print("Move one finger along all edges of the touchpad".center(self.columns))
  115. print("until the detected axis range stops changing.".center(self.columns))
  116. top = int(self.pos.y * self.rows)
  117. print(f"+{''.ljust(self.columns, '-')}+")
  118. for row in range(top):
  119. print(f"|{''.ljust(self.columns)}|")
  120. left = int(self.pos.x * self.columns)
  121. right = max(0, self.columns - 1 - left)
  122. print(f"|{''.ljust(left)}O{''.ljust(right)}|")
  123. for row in range(top + 1, self.rows):
  124. print(f"|{''.ljust(self.columns)}|")
  125. print(f"+{''.ljust(self.columns, '-')}+")
  126. print("Press Ctrl+C to stop".center(self.columns))
  127. print(f"\033[{self.rows + 8}A", flush=True)
  128. self.rows_printed = self.rows + 8
  129. def erase(self):
  130. # Erase all previous lines so we're not left with rubbish
  131. for row in range(self.rows_printed):
  132. print("\033[K")
  133. print(f"\033[{self.rows_printed}A")
  134. def dimension(string):
  135. try:
  136. ts = string.split("x")
  137. t = tuple([int(x) for x in ts])
  138. if len(t) == 2:
  139. return t
  140. except: # noqa
  141. pass
  142. msg = f"{string} is not in format WxH"
  143. raise argparse.ArgumentTypeError(msg)
  144. def between(v1, v2, deviation):
  145. return v1 - deviation < v2 < v1 + deviation
  146. def dmi_modalias_match(modalias):
  147. modalias = modalias.split(":")
  148. dmi = {"svn": None, "pvr": None, "pn": None}
  149. for m in modalias:
  150. for key in dmi:
  151. if m.startswith(key):
  152. dmi[key] = m[len(key) :]
  153. # Based on the current 60-evdev.hwdb, Lenovo uses pvr and everyone else
  154. # uses pn to provide a human-identifiable match
  155. if dmi["svn"] == "LENOVO":
  156. return f"dmi:*svn{dmi['svn']}:*pvr{dmi['pvr']}*"
  157. else:
  158. return f"dmi:*svn{dmi['svn']}:*pn{dmi['pn']}*"
  159. def main(args):
  160. parser = argparse.ArgumentParser(description="Measure the touchpad size")
  161. parser.add_argument(
  162. "size",
  163. metavar="WxH",
  164. type=dimension,
  165. help="Touchpad size (width by height) in mm",
  166. )
  167. parser.add_argument(
  168. "path",
  169. metavar="/dev/input/event0",
  170. nargs="?",
  171. type=str,
  172. help="Path to device (optional)",
  173. )
  174. context = pyudev.Context()
  175. args = parser.parse_args()
  176. if not args.path:
  177. for device in context.list_devices(subsystem="input"):
  178. if device.get("ID_INPUT_TOUCHPAD", 0) and (
  179. device.device_node or ""
  180. ).startswith("/dev/input/event"):
  181. args.path = device.device_node
  182. name = "unknown"
  183. parent = device
  184. while parent is not None:
  185. n = parent.get("NAME", None)
  186. if n:
  187. name = n
  188. break
  189. parent = parent.parent
  190. print(f"Using {name}: {device.device_node}")
  191. break
  192. else:
  193. print("Unable to find a touchpad device.", file=sys.stderr)
  194. return 1
  195. dev = pyudev.Devices.from_device_file(context, args.path)
  196. overrides = [p for p in dev.properties if p.startswith("EVDEV_ABS")]
  197. if overrides:
  198. print()
  199. print("********************************************************************")
  200. print("WARNING: axis overrides already in place for this device:")
  201. for prop in overrides:
  202. print(f" {prop}={dev.properties[prop]}")
  203. print("The systemd hwdb already overrides the axis ranges and/or resolution.")
  204. print("This tool is not needed unless you want to verify the axis overrides.")
  205. print("********************************************************************")
  206. print()
  207. try:
  208. fd = open(args.path, "rb") # noqa: SIM115
  209. evdev = libevdev.Device(fd)
  210. touchpad = Touchpad(evdev)
  211. print(
  212. f"Kernel specified touchpad size: {touchpad.width:.1f}x{touchpad.height:.1f}mm"
  213. )
  214. print(
  215. f"User specified touchpad size: {args.size[0]:.1f}x{args.size[1]:.1f}mm"
  216. )
  217. print()
  218. print(
  219. f"Kernel axis range: x [{touchpad.x.minimum:4d}..{touchpad.x.maximum:4d}],"
  220. f" y [{touchpad.y.minimum:4d}..{touchpad.y.maximum:4d}]"
  221. )
  222. print("Put your finger on the touchpad to start\033[1A")
  223. try:
  224. touchpad.draw()
  225. while True:
  226. for event in evdev.events():
  227. if event.matches(libevdev.EV_ABS.ABS_X):
  228. touchpad.x = event.value
  229. elif event.matches(libevdev.EV_ABS.ABS_Y):
  230. touchpad.y = event.value
  231. elif event.matches(libevdev.EV_SYN.SYN_REPORT):
  232. touchpad.draw()
  233. except KeyboardInterrupt:
  234. touchpad.erase()
  235. touchpad.update_from_data()
  236. print(
  237. f"Detected axis range: x [{touchpad.x.minimum:4d}..{touchpad.x.maximum:4d}],"
  238. f" y [{touchpad.y.minimum:4d}..{touchpad.y.maximum:4d}]"
  239. )
  240. touchpad.x.resolution = round(
  241. (touchpad.x.maximum - touchpad.x.minimum) / args.size[0]
  242. )
  243. touchpad.y.resolution = round(
  244. (touchpad.y.maximum - touchpad.y.minimum) / args.size[1]
  245. )
  246. print(
  247. f"Resolutions calculated based on user-specified size:"
  248. f" x {touchpad.x.resolution}, y {touchpad.y.resolution} units/mm"
  249. )
  250. # If both x/y are within some acceptable deviation, we skip the axis
  251. # overrides and only override the resolution
  252. xorig = evdev.absinfo[libevdev.EV_ABS.ABS_X]
  253. yorig = evdev.absinfo[libevdev.EV_ABS.ABS_Y]
  254. deviation = 1.5 * touchpad.x.resolution # 1.5 mm rounding on each side
  255. skip = between(xorig.minimum, touchpad.x.minimum, deviation)
  256. skip = skip and between(xorig.maximum, touchpad.x.maximum, deviation)
  257. deviation = 1.5 * touchpad.y.resolution # 1.5 mm rounding on each side
  258. skip = skip and between(yorig.minimum, touchpad.y.minimum, deviation)
  259. skip = skip and between(yorig.maximum, touchpad.y.maximum, deviation)
  260. if skip:
  261. print()
  262. print(
  263. "Note: Axis ranges within acceptable deviation, skipping min/max override"
  264. )
  265. print()
  266. print()
  267. print("Suggested hwdb entry:")
  268. use_dmi = evdev.id["bustype"] not in [0x03, 0x05] # USB, Bluetooth
  269. if use_dmi:
  270. with open("/sys/class/dmi/id/modalias") as f:
  271. modalias = f.read().strip()
  272. print(
  273. "Note: the dmi modalias match is a guess based on your machine's modalias:"
  274. )
  275. print(" ", modalias)
  276. print(
  277. "Please verify that this is the most sensible match and adjust if necessary."
  278. )
  279. print("-8<--------------------------")
  280. print("# Laptop model description (e.g. Lenovo X1 Carbon 5th)")
  281. if use_dmi:
  282. print(f"evdev:name:{evdev.name}:{dmi_modalias_match(modalias)}*")
  283. else:
  284. print(
  285. f"evdev:input:b{evdev.id['bustype']:04X}"
  286. f"v{evdev.id['vendor']:04X}"
  287. f"p{evdev.id['product']:04X}*"
  288. )
  289. xmin = touchpad.x.minimum if not skip else ""
  290. xmax = touchpad.x.maximum if not skip else ""
  291. ymin = touchpad.y.minimum if not skip else ""
  292. ymax = touchpad.y.maximum if not skip else ""
  293. print(f" EVDEV_ABS_00={xmin}:{xmax}:{touchpad.x.resolution}")
  294. print(f" EVDEV_ABS_01={ymin}:{ymax}:{touchpad.y.resolution}")
  295. if evdev.absinfo[libevdev.EV_ABS.ABS_MT_POSITION_X]:
  296. print(f" EVDEV_ABS_35={xmin}:{xmax}:{touchpad.x.resolution}")
  297. print(f" EVDEV_ABS_36={ymin}:{ymax}:{touchpad.y.resolution}")
  298. print("-8<--------------------------")
  299. print(
  300. "Instructions on what to do with this snippet are in /usr/lib/udev/hwdb.d/60-evdev.hwdb"
  301. )
  302. except DeviceError as e:
  303. print(f"Error: {e}", file=sys.stderr)
  304. return 1
  305. except PermissionError:
  306. print("Unable to open device. Please run me as root", file=sys.stderr)
  307. return 1
  308. return 0
  309. if __name__ == "__main__":
  310. sys.exit(main(sys.argv))