• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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