1#!/usr/bin/env python3
2# vim: set expandtab shiftwidth=4:
3# -*- Mode: python; coding: utf-8; indent-tabs-mode: nil -*- */
4#
5# Copyright © 2018 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 os
28import sys
29import argparse
30import subprocess
31
32try:
33    import libevdev
34    import pyudev
35except ModuleNotFoundError as e:
36    print("Error: {}".format(str(e)), file=sys.stderr)
37    print(
38        "One or more python modules are missing. Please install those "
39        "modules and re-run this tool."
40    )
41    sys.exit(1)
42
43
44DEFAULT_HWDB_FILE = "/usr/lib/udev/hwdb.d/60-evdev.hwdb"
45OVERRIDE_HWDB_FILE = "/etc/udev/hwdb.d/99-touchpad-fuzz-override.hwdb"
46
47
48class tcolors:
49    GREEN = "\033[92m"
50    RED = "\033[91m"
51    YELLOW = "\033[93m"
52    BOLD = "\033[1m"
53    NORMAL = "\033[0m"
54
55
56def print_bold(msg, **kwargs):
57    print(tcolors.BOLD + msg + tcolors.NORMAL, **kwargs)
58
59
60def print_green(msg, **kwargs):
61    print(tcolors.BOLD + tcolors.GREEN + msg + tcolors.NORMAL, **kwargs)
62
63
64def print_yellow(msg, **kwargs):
65    print(tcolors.BOLD + tcolors.YELLOW + msg + tcolors.NORMAL, **kwargs)
66
67
68def print_red(msg, **kwargs):
69    print(tcolors.BOLD + tcolors.RED + msg + tcolors.NORMAL, **kwargs)
70
71
72class InvalidConfigurationError(Exception):
73    pass
74
75
76class InvalidDeviceError(Exception):
77    pass
78
79
80class Device(libevdev.Device):
81    def __init__(self, path):
82        if path is None:
83            self.path = self.find_touch_device()
84        else:
85            self.path = path
86
87        fd = open(self.path, "rb")
88        super().__init__(fd)
89        context = pyudev.Context()
90        self.udev_device = pyudev.Devices.from_device_file(context, self.path)
91
92    def find_touch_device(self):
93        context = pyudev.Context()
94        for device in context.list_devices(subsystem="input"):
95            if not device.get("ID_INPUT_TOUCHPAD", 0):
96                continue
97
98            if not device.device_node or not device.device_node.startswith(
99                "/dev/input/event"
100            ):
101                continue
102
103            return device.device_node
104
105        print("Unable to find a touch device.", file=sys.stderr)
106        sys.exit(1)
107
108    def check_property(self):
109        """Return a tuple of (xfuzz, yfuzz) with the fuzz as set in the libinput
110        property. Returns None if the property doesn't exist"""
111
112        axes = {
113            0x00: self.udev_device.get("LIBINPUT_FUZZ_00"),
114            0x01: self.udev_device.get("LIBINPUT_FUZZ_01"),
115            0x35: self.udev_device.get("LIBINPUT_FUZZ_35"),
116            0x36: self.udev_device.get("LIBINPUT_FUZZ_36"),
117        }
118
119        if axes[0x35] is not None:
120            if axes[0x35] != axes[0x00]:
121                print_bold(
122                    "WARNING: fuzz mismatch ABS_X: {}, ABS_MT_POSITION_X: {}".format(
123                        axes[0x00], axes[0x35]
124                    )
125                )
126
127        if axes[0x36] is not None:
128            if axes[0x36] != axes[0x01]:
129                print_bold(
130                    "WARNING: fuzz mismatch ABS_Y: {}, ABS_MT_POSITION_Y: {}".format(
131                        axes[0x01], axes[0x36]
132                    )
133                )
134
135        xfuzz = axes[0x35] or axes[0x00]
136        yfuzz = axes[0x36] or axes[0x01]
137
138        if xfuzz is None and yfuzz is None:
139            return None
140
141        if (xfuzz is not None and yfuzz is None) or (
142            xfuzz is None and yfuzz is not None
143        ):
144            raise InvalidConfigurationError("fuzz should be set for both axes")
145
146        return (int(xfuzz), int(yfuzz))
147
148    def check_axes(self):
149        """
150        Returns a tuple of (xfuzz, yfuzz) with the fuzz as set on the device
151        axis. Returns None if no fuzz is set.
152        """
153        if not self.has(libevdev.EV_ABS.ABS_X) or not self.has(libevdev.EV_ABS.ABS_Y):
154            raise InvalidDeviceError("device does not have x/y axes")
155
156        if self.has(libevdev.EV_ABS.ABS_MT_POSITION_X) != self.has(
157            libevdev.EV_ABS.ABS_MT_POSITION_Y
158        ):
159            raise InvalidDeviceError("device does not have both multitouch axes")
160
161        xfuzz = (
162            self.absinfo[libevdev.EV_ABS.ABS_X].fuzz
163            or self.absinfo[libevdev.EV_ABS.ABS_MT_POSITION_X].fuzz
164        )
165        yfuzz = (
166            self.absinfo[libevdev.EV_ABS.ABS_Y].fuzz
167            or self.absinfo[libevdev.EV_ABS.ABS_MT_POSITION_Y].fuzz
168        )
169
170        if xfuzz == 0 and yfuzz == 0:
171            return None
172
173        return (xfuzz, yfuzz)
174
175
176def print_fuzz(what, fuzz):
177    print("  Checking {}... ".format(what), end="")
178    if fuzz is None:
179        print("not set")
180    elif fuzz == (0, 0):
181        print("is zero")
182    else:
183        print("x={} y={}".format(*fuzz))
184
185
186def handle_existing_entry(device, fuzz):
187    # This is getting messy because we don't really know where the entry
188    # could be or how the match rule looks like. So we just check the
189    # default location only.
190    # For the match comparison, we search for the property value in the
191    # file. If there is more than one entry that uses the same
192    # overrides this will generate false positives.
193    # If the lines aren't in the same order in the file, it'll be a false
194    # negative.
195    overrides = {
196        0x00: device.udev_device.get("EVDEV_ABS_00"),
197        0x01: device.udev_device.get("EVDEV_ABS_01"),
198        0x35: device.udev_device.get("EVDEV_ABS_35"),
199        0x36: device.udev_device.get("EVDEV_ABS_36"),
200    }
201
202    has_existing_rules = False
203    for key, value in overrides.items():
204        if value is not None:
205            has_existing_rules = True
206            break
207    if not has_existing_rules:
208        return False
209
210    print_red("Error! ", end="")
211    print("This device already has axis overrides defined")
212    print("")
213    print_bold("Searching for existing override...")
214
215    # Construct a template that looks like a hwdb entry (values only) from
216    # the udev property values
217    template = [
218        " EVDEV_ABS_00={}".format(overrides[0x00]),
219        " EVDEV_ABS_01={}".format(overrides[0x01]),
220    ]
221    if overrides[0x35] is not None:
222        template += [
223            " EVDEV_ABS_35={}".format(overrides[0x35]),
224            " EVDEV_ABS_36={}".format(overrides[0x36]),
225        ]
226
227    print("Checking in {}... ".format(OVERRIDE_HWDB_FILE), end="")
228    entry, prefix, lineno = check_file_for_lines(OVERRIDE_HWDB_FILE, template)
229    if entry is not None:
230        print_green("found")
231        print("The existing hwdb entry can be overwritten")
232        return False
233    else:
234        print_red("not found")
235        print("Checking in {}... ".format(DEFAULT_HWDB_FILE), end="")
236        entry, prefix, lineno = check_file_for_lines(DEFAULT_HWDB_FILE, template)
237        if entry is not None:
238            print_green("found")
239        else:
240            print_red("not found")
241            print(
242                "The device has a hwdb override defined but it's not where I expected it to be."
243            )
244            print("Please look at the libinput documentation for more details.")
245            print("Exiting now.")
246            return True
247
248    print_bold("Probable entry for this device found in line {}:".format(lineno))
249    print("\n".join(prefix + entry))
250    print("")
251
252    print_bold("Suggested new entry for this device:")
253    new_entry = []
254    for i in range(0, len(template)):
255        parts = entry[i].split(":")
256        while len(parts) < 4:
257            parts.append("")
258        parts[3] = str(fuzz)
259        new_entry.append(":".join(parts))
260    print("\n".join(prefix + new_entry))
261    print("")
262
263    # Not going to overwrite the 60-evdev.hwdb entry with this program, too
264    # risky. And it may not be our device match anyway.
265    print_bold("You must now:")
266    print(
267        "\n".join(
268            (
269                "1. Check the above suggestion for sanity. Does it match your device?",
270                "2. Open {} and amend the existing entry".format(DEFAULT_HWDB_FILE),
271                "   as recommended above",
272                "",
273                "   The property format is:",
274                "    EVDEV_ABS_00=min:max:resolution:fuzz",
275                "",
276                "   Leave the entry as-is and only add or amend the fuzz value.",
277                "   A non-existent value can be skipped, e.g. this entry sets the ",
278                "   resolution to 32 and the fuzz to 8",
279                "    EVDEV_ABS_00=::32:8",
280                "",
281                "3. Save the edited file",
282                "4. Say Y to the next prompt",
283            )
284        )
285    )
286
287    cont = input("Continue? [Y/n] ")
288    if cont == "n":
289        raise KeyboardInterrupt
290
291    if test_hwdb_entry(device, fuzz):
292        print_bold("Please test the new fuzz setting by restarting libinput")
293        print_bold(
294            "Then submit a pull request for this hwdb entry change to "
295            "to systemd at http://github.com/systemd/systemd"
296        )
297    else:
298        print_bold("The new fuzz setting did not take effect.")
299        print_bold("Did you edit the correct file?")
300        print("Please look at the libinput documentation for more details.")
301        print("Exiting now.")
302
303    return True
304
305
306def reload_and_trigger_udev(device):
307    import time
308
309    print("Running systemd-hwdb update")
310    subprocess.run(["systemd-hwdb", "update"], check=True)
311    syspath = device.path.replace("/dev/input/", "/sys/class/input/")
312    time.sleep(2)
313    print("Running udevadm trigger {}".format(syspath))
314    subprocess.run(["udevadm", "trigger", syspath], check=True)
315    time.sleep(2)
316
317
318def test_hwdb_entry(device, fuzz):
319    reload_and_trigger_udev(device)
320    print_bold("Testing... ", end="")
321
322    d = Device(device.path)
323    f = d.check_axes()
324    if f is not None:
325        if f == (fuzz, fuzz):
326            print_yellow("Warning")
327            print_bold(
328                "The hwdb applied to the device but libinput's udev "
329                "rules have not picked it up. This should only happen"
330                "if libinput is not installed"
331            )
332            return True
333        else:
334            print_red("Error")
335            return False
336    else:
337        f = d.check_property()
338        if f is not None and f == (fuzz, fuzz):
339            print_green("Success")
340            return True
341        else:
342            print_red("Error")
343            return False
344
345
346def check_file_for_lines(path, template):
347    """
348    Checks file at path for the lines given in template. If found, the
349    return value is a tuple of the matching lines and the prefix (i.e. the
350    two lines before the matching lines)
351    """
352    try:
353        lines = [l[:-1] for l in open(path).readlines()]
354        idx = -1
355        try:
356            while idx < len(lines) - 1:
357                idx += 1
358                line = lines[idx]
359                if not line.startswith(" EVDEV_ABS_00"):
360                    continue
361                if lines[idx : idx + len(template)] != template:
362                    continue
363
364                return (lines[idx : idx + len(template)], lines[idx - 2 : idx], idx)
365
366        except IndexError:
367            pass
368    except FileNotFoundError:
369        pass
370
371    return (None, None, None)
372
373
374def write_udev_rule(device, fuzz):
375    """Write out a udev rule that may match the device, run udevadm trigger and
376    check if the udev rule worked. Of course, there's plenty to go wrong...
377    """
378    print("")
379    print_bold("Guessing a udev rule to overwrite the fuzz")
380
381    # Some devices match better on pvr, others on pn, so we get to try both. yay
382    modalias = open("/sys/class/dmi/id/modalias").readlines()[0]
383    ms = modalias.split(":")
384    svn, pn, pvr = None, None, None
385    for m in ms:
386        if m.startswith("svn"):
387            svn = m
388        elif m.startswith("pn"):
389            pn = m
390        elif m.startswith("pvr"):
391            pvr = m
392
393    # Let's print out both to inform and/or confuse the user
394    template = "\n".join(
395        (
396            "# {} {}",
397            "evdev:name:{}:dmi:*:{}*:{}*:",
398            " EVDEV_ABS_00=:::{}",
399            " EVDEV_ABS_01=:::{}",
400            " EVDEV_ABS_35=:::{}",
401            " EVDEV_ABS_36=:::{}",
402            "",
403        )
404    )
405    rule1 = template.format(
406        svn[3:], device.name, device.name, svn, pvr, fuzz, fuzz, fuzz, fuzz
407    )
408    rule2 = template.format(
409        svn[3:], device.name, device.name, svn, pn, fuzz, fuzz, fuzz, fuzz
410    )
411
412    print("Full modalias is: {}".format(modalias))
413    print()
414    print_bold("Suggested udev rule, option 1:")
415    print(rule1)
416    print()
417    print_bold("Suggested udev rule, option 2:")
418    print(rule2)
419    print("")
420
421    # The weird hwdb matching behavior means we match on the least specific
422    # rule (i.e. most wildcards) first although that was supposed to be fixed in
423    # systemd 3a04b789c6f1.
424    # Our rule uses dmi strings and will be more specific than what 60-evdev.hwdb
425    # already has. So we basically throw up our hands because we can't do anything
426    # then.
427    if handle_existing_entry(device, fuzz):
428        return
429
430    while True:
431        print_bold("Wich rule do you want to to test? 1 or 2? ", end="")
432        yesno = input("Ctrl+C to exit ")
433
434        if yesno == "1":
435            rule = rule1
436            break
437        elif yesno == "2":
438            rule = rule2
439            break
440
441    fname = OVERRIDE_HWDB_FILE
442    try:
443        fd = open(fname, "x")
444    except FileExistsError:
445        yesno = input("File {} exists, overwrite? [Y/n] ".format(fname))
446        if yesno.lower == "n":
447            return
448
449        fd = open(fname, "w")
450
451    fd.write("# File generated by libinput measure fuzz\n\n")
452    fd.write(rule)
453    fd.close()
454
455    if test_hwdb_entry(device, fuzz):
456        print("Your hwdb override file is in {}".format(fname))
457        print_bold("Please test the new fuzz setting by restarting libinput")
458        print_bold(
459            "Then submit a pull request for this hwdb entry to "
460            "systemd at http://github.com/systemd/systemd"
461        )
462    else:
463        print("The hwdb entry failed to apply to the device.")
464        print("Removing hwdb file again.")
465        os.remove(fname)
466        reload_and_trigger_udev(device)
467        print_bold("What now?")
468        print(
469            "1. Re-run this program and try the other suggested udev rule. If that fails,"
470        )
471        print(
472            "2. File a bug with the suggested udev rule at http://github.com/systemd/systemd"
473        )
474
475
476def main(args):
477    parser = argparse.ArgumentParser(
478        description="Print fuzz settings and/or suggest udev rules for the fuzz to be adjusted."
479    )
480    parser.add_argument(
481        "path",
482        metavar="/dev/input/event0",
483        nargs="?",
484        type=str,
485        help="Path to device (optional)",
486    )
487    parser.add_argument("--fuzz", type=int, help="Suggested fuzz")
488    args = parser.parse_args()
489
490    try:
491        device = Device(args.path)
492        print_bold("Using {}: {}".format(device.name, device.path))
493
494        fuzz = device.check_property()
495        print_fuzz("udev property", fuzz)
496
497        fuzz = device.check_axes()
498        print_fuzz("axes", fuzz)
499
500        userfuzz = args.fuzz
501        if userfuzz is not None:
502            write_udev_rule(device, userfuzz)
503
504    except PermissionError:
505        print("Permission denied, please re-run as root")
506    except InvalidConfigurationError as e:
507        print("Error: {}".format(e))
508    except InvalidDeviceError as e:
509        print("Error: {}".format(e))
510    except KeyboardInterrupt:
511        print("Exited on user request")
512
513
514if __name__ == "__main__":
515    main(sys.argv)
516