#!/usr/bin/env python3 # vim: set expandtab shiftwidth=4: # -*- Mode: python; coding: utf-8; indent-tabs-mode: nil -*- */ # # Copyright © 2020 Red Hat, Inc. # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), # to deal in the Software without restriction, including without limitation # the rights to use, copy, modify, merge, publish, distribute, sublicense, # and/or sell copies of the Software, and to permit persons to whom the # Software is furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice (including the next # paragraph) shall be included in all copies or substantial portions of the # Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. import argparse import sys try: import libevdev import pyudev except ModuleNotFoundError as e: print(f"Error: {e!s}", file=sys.stderr) print( "One or more python modules are missing. Please install those " "modules and re-run this tool." ) sys.exit(1) class DeviceError(Exception): pass class Point: def __init__(self, x=None, y=None): self.x = x self.y = y class Touchpad: def __init__(self, evdev): x = evdev.absinfo[libevdev.EV_ABS.ABS_X] y = evdev.absinfo[libevdev.EV_ABS.ABS_Y] if not x or not y: raise DeviceError("Device does not have an x or axis") if not x.resolution or not y.resolution: print("Device does not have resolutions.", file=sys.stderr) x.resolution = 1 y.resolution = 1 self.xrange = x.maximum - x.minimum self.yrange = y.maximum - y.minimum self.width = self.xrange / x.resolution self.height = self.yrange / y.resolution self._x = x self._y = y # We try to make the touchpad at least look proportional. The # terminal character space is (guesswork) ca 2.3 times as high as # wide. self.columns = 30 self.rows = int( self.columns * (self.yrange // y.resolution) // (self.xrange // x.resolution) / 2.3 ) self.pos = Point(0, 0) self.min = Point() self.max = Point() @property def x(self): return self._x @property def y(self): return self._y @x.setter def x(self, x): self._x.minimum = min(self.x.minimum, x) self._x.maximum = max(self.x.maximum, x) self.min.x = min(x, self.min.x or 0xFFFFFFFF) self.max.x = max(x, self.max.x or -0xFFFFFFFF) # we calculate the position based on the original range. # this means on devices with a narrower range than advertised, not # all corners may be reachable in the touchpad drawing. self.pos.x = min(0.99, (x - self._x.minimum) / self.xrange) @y.setter def y(self, y): self._y.minimum = min(self.y.minimum, y) self._y.maximum = max(self.y.maximum, y) self.min.y = min(y, self.min.y or 0xFFFFFFFF) self.max.y = max(y, self.max.y or -0xFFFFFFFF) # we calculate the position based on the original range. # this means on devices with a narrower range than advertised, not # all corners may be reachable in the touchpad drawing. self.pos.y = min(0.99, (y - self._y.minimum) / self.yrange) def update_from_data(self): if None in [self.min.x, self.min.y, self.max.x, self.max.y]: raise DeviceError("Insufficient data to continue") self._x.minimum = self.min.x self._x.maximum = self.max.x self._y.minimum = self.min.y self._y.maximum = self.max.y def draw(self): min_x = self.min.x if self.min.x is not None else 0 max_x = self.max.x if self.max.x is not None else 0 min_y = self.min.y if self.min.y is not None else 0 max_y = self.max.y if self.max.y is not None else 0 print( f"Detected axis range: x [{min_x:4d}..{max_x:4d}], y [{min_y:4d}..{max_y:4d}]" ) print() print("Move one finger along all edges of the touchpad".center(self.columns)) print("until the detected axis range stops changing.".center(self.columns)) top = int(self.pos.y * self.rows) print(f"+{''.ljust(self.columns, '-')}+") for row in range(top): print(f"|{''.ljust(self.columns)}|") left = int(self.pos.x * self.columns) right = max(0, self.columns - 1 - left) print(f"|{''.ljust(left)}O{''.ljust(right)}|") for row in range(top + 1, self.rows): print(f"|{''.ljust(self.columns)}|") print(f"+{''.ljust(self.columns, '-')}+") print("Press Ctrl+C to stop".center(self.columns)) print(f"\033[{self.rows + 8}A", flush=True) self.rows_printed = self.rows + 8 def erase(self): # Erase all previous lines so we're not left with rubbish for row in range(self.rows_printed): print("\033[K") print(f"\033[{self.rows_printed}A") def dimension(string): try: ts = string.split("x") t = tuple([int(x) for x in ts]) if len(t) == 2: return t except: # noqa pass msg = f"{string} is not in format WxH" raise argparse.ArgumentTypeError(msg) def between(v1, v2, deviation): return v1 - deviation < v2 < v1 + deviation def dmi_modalias_match(modalias): modalias = modalias.split(":") dmi = {"svn": None, "pvr": None, "pn": None} for m in modalias: for key in dmi: if m.startswith(key): dmi[key] = m[len(key) :] # Based on the current 60-evdev.hwdb, Lenovo uses pvr and everyone else # uses pn to provide a human-identifiable match if dmi["svn"] == "LENOVO": return f"dmi:*svn{dmi['svn']}:*pvr{dmi['pvr']}*" else: return f"dmi:*svn{dmi['svn']}:*pn{dmi['pn']}*" def main(args): parser = argparse.ArgumentParser(description="Measure the touchpad size") parser.add_argument( "size", metavar="WxH", type=dimension, help="Touchpad size (width by height) in mm", ) parser.add_argument( "path", metavar="/dev/input/event0", nargs="?", type=str, help="Path to device (optional)", ) context = pyudev.Context() args = parser.parse_args() if not args.path: for device in context.list_devices(subsystem="input"): if device.get("ID_INPUT_TOUCHPAD", 0) and ( device.device_node or "" ).startswith("/dev/input/event"): args.path = device.device_node name = "unknown" parent = device while parent is not None: n = parent.get("NAME", None) if n: name = n break parent = parent.parent print(f"Using {name}: {device.device_node}") break else: print("Unable to find a touchpad device.", file=sys.stderr) return 1 dev = pyudev.Devices.from_device_file(context, args.path) overrides = [p for p in dev.properties if p.startswith("EVDEV_ABS")] if overrides: print() print("********************************************************************") print("WARNING: axis overrides already in place for this device:") for prop in overrides: print(f" {prop}={dev.properties[prop]}") print("The systemd hwdb already overrides the axis ranges and/or resolution.") print("This tool is not needed unless you want to verify the axis overrides.") print("********************************************************************") print() try: fd = open(args.path, "rb") # noqa: SIM115 evdev = libevdev.Device(fd) touchpad = Touchpad(evdev) print( f"Kernel specified touchpad size: {touchpad.width:.1f}x{touchpad.height:.1f}mm" ) print( f"User specified touchpad size: {args.size[0]:.1f}x{args.size[1]:.1f}mm" ) print() print( f"Kernel axis range: x [{touchpad.x.minimum:4d}..{touchpad.x.maximum:4d}]," f" y [{touchpad.y.minimum:4d}..{touchpad.y.maximum:4d}]" ) print("Put your finger on the touchpad to start\033[1A") try: touchpad.draw() while True: for event in evdev.events(): if event.matches(libevdev.EV_ABS.ABS_X): touchpad.x = event.value elif event.matches(libevdev.EV_ABS.ABS_Y): touchpad.y = event.value elif event.matches(libevdev.EV_SYN.SYN_REPORT): touchpad.draw() except KeyboardInterrupt: touchpad.erase() touchpad.update_from_data() print( f"Detected axis range: x [{touchpad.x.minimum:4d}..{touchpad.x.maximum:4d}]," f" y [{touchpad.y.minimum:4d}..{touchpad.y.maximum:4d}]" ) touchpad.x.resolution = round( (touchpad.x.maximum - touchpad.x.minimum) / args.size[0] ) touchpad.y.resolution = round( (touchpad.y.maximum - touchpad.y.minimum) / args.size[1] ) print( f"Resolutions calculated based on user-specified size:" f" x {touchpad.x.resolution}, y {touchpad.y.resolution} units/mm" ) # If both x/y are within some acceptable deviation, we skip the axis # overrides and only override the resolution xorig = evdev.absinfo[libevdev.EV_ABS.ABS_X] yorig = evdev.absinfo[libevdev.EV_ABS.ABS_Y] deviation = 1.5 * touchpad.x.resolution # 1.5 mm rounding on each side skip = between(xorig.minimum, touchpad.x.minimum, deviation) skip = skip and between(xorig.maximum, touchpad.x.maximum, deviation) deviation = 1.5 * touchpad.y.resolution # 1.5 mm rounding on each side skip = skip and between(yorig.minimum, touchpad.y.minimum, deviation) skip = skip and between(yorig.maximum, touchpad.y.maximum, deviation) if skip: print() print( "Note: Axis ranges within acceptable deviation, skipping min/max override" ) print() print() print("Suggested hwdb entry:") use_dmi = evdev.id["bustype"] not in [0x03, 0x05] # USB, Bluetooth if use_dmi: with open("/sys/class/dmi/id/modalias") as f: modalias = f.read().strip() print( "Note: the dmi modalias match is a guess based on your machine's modalias:" ) print(" ", modalias) print( "Please verify that this is the most sensible match and adjust if necessary." ) print("-8<--------------------------") print("# Laptop model description (e.g. Lenovo X1 Carbon 5th)") if use_dmi: print(f"evdev:name:{evdev.name}:{dmi_modalias_match(modalias)}*") else: print( f"evdev:input:b{evdev.id['bustype']:04X}" f"v{evdev.id['vendor']:04X}" f"p{evdev.id['product']:04X}*" ) xmin = touchpad.x.minimum if not skip else "" xmax = touchpad.x.maximum if not skip else "" ymin = touchpad.y.minimum if not skip else "" ymax = touchpad.y.maximum if not skip else "" print(f" EVDEV_ABS_00={xmin}:{xmax}:{touchpad.x.resolution}") print(f" EVDEV_ABS_01={ymin}:{ymax}:{touchpad.y.resolution}") if evdev.absinfo[libevdev.EV_ABS.ABS_MT_POSITION_X]: print(f" EVDEV_ABS_35={xmin}:{xmax}:{touchpad.x.resolution}") print(f" EVDEV_ABS_36={ymin}:{ymax}:{touchpad.y.resolution}") print("-8<--------------------------") print( "Instructions on what to do with this snippet are in /usr/lib/udev/hwdb.d/60-evdev.hwdb" ) except DeviceError as e: print(f"Error: {e}", file=sys.stderr) return 1 except PermissionError: print("Unable to open device. Please run me as root", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main(sys.argv))