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