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