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