#!/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 sys import argparse try: import libevdev import pyudev except ModuleNotFoundError as e: print("Error: {}".format(str(e)), 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(object): 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): print( "Detected axis range: x [{:4d}..{:4d}], y [{:4d}..{:4d}]".format( self.min.x if self.min.x is not None else 0, self.max.x if self.max.x is not None else 0, self.min.y if self.min.y is not None else 0, self.max.y if self.max.y is not None else 0, ) ) 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("+{}+".format("".ljust(self.columns, "-"))) for row in range(0, top): print("|{}|".format("".ljust(self.columns))) left = int(self.pos.x * self.columns) right = max(0, self.columns - 1 - left) print("|{}{}{}|".format("".ljust(left), "O", "".ljust(right))) for row in range(top + 1, self.rows): print("|{}|".format("".ljust(self.columns))) print("+{}+".format("".ljust(self.columns, "-"))) print("Press Ctrl+C to stop".center(self.columns)) print("\033[{}A".format(self.rows + 8), 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("\033[{}A".format(self.rows_printed)) 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 = "{} is not in format WxH".format(string) 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 "dmi:*svn{}:*pvr{}*".format(dmi["svn"], dmi["pvr"]) else: return "dmi:*svn{}:*pn{}*".format(dmi["svn"], 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("Using {}: {}".format(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(" {}={}".format(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") evdev = libevdev.Device(fd) touchpad = Touchpad(evdev) print( "Kernel specified touchpad size: {:.1f}x{:.1f}mm".format( touchpad.width, touchpad.height ) ) print("User specified touchpad size: {:.1f}x{:.1f}mm".format(*args.size)) print() print( "Kernel axis range: x [{:4d}..{:4d}], y [{:4d}..{:4d}]".format( touchpad.x.minimum, touchpad.x.maximum, touchpad.y.minimum, touchpad.y.maximum, ) ) 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( "Detected axis range: x [{:4d}..{:4d}], y [{:4d}..{:4d}]".format( touchpad.x.minimum, touchpad.x.maximum, touchpad.y.minimum, touchpad.y.maximum, ) ) 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( "Resolutions calculated based on user-specified size: x {}, y {} units/mm".format( touchpad.x.resolution, touchpad.y.resolution ) ) # 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: modalias = open("/sys/class/dmi/id/modalias").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("evdev:name:{}:{}*".format(evdev.name, dmi_modalias_match(modalias))) else: print( "evdev:input:b{:04X}v{:04X}p{:04X}*".format( evdev.id["bustype"], evdev.id["vendor"], evdev.id["product"] ) ) print( " EVDEV_ABS_00={}:{}:{}".format( touchpad.x.minimum if not skip else "", touchpad.x.maximum if not skip else "", touchpad.x.resolution, ) ) print( " EVDEV_ABS_01={}:{}:{}".format( touchpad.y.minimum if not skip else "", touchpad.y.maximum if not skip else "", touchpad.y.resolution, ) ) if evdev.absinfo[libevdev.EV_ABS.ABS_MT_POSITION_X]: print( " EVDEV_ABS_35={}:{}:{}".format( touchpad.x.minimum if not skip else "", touchpad.x.maximum if not skip else "", touchpad.x.resolution, ) ) print( " EVDEV_ABS_36={}:{}:{}".format( touchpad.y.minimum if not skip else "", touchpad.y.maximum if not skip else "", 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("Error: {}".format(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))