1#!/usr/bin/env python3
2# -*- coding: utf-8
3# vim: set expandtab shiftwidth=4:
4# -*- Mode: python; coding: utf-8; indent-tabs-mode: nil -*- */
5#
6# Copyright © 2021 Red Hat, Inc.
7#
8# Permission is hereby granted, free of charge, to any person obtaining a
9# copy of this software and associated documentation files (the 'Software'),
10# to deal in the Software without restriction, including without limitation
11# the rights to use, copy, modify, merge, publish, distribute, sublicense,
12# and/or sell copies of the Software, and to permit persons to whom the
13# Software is furnished to do so, subject to the following conditions:
14#
15# The above copyright notice and this permission notice (including the next
16# paragraph) shall be included in all copies or substantial portions of the
17# Software.
18#
19# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
22# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
24# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
25# DEALINGS IN THE SOFTWARE.
26#
27# Prints the data from a libinput recording in a table format to ease
28# debugging.
29#
30# Input is a libinput record yaml file
31
32import argparse
33import os
34import sys
35import yaml
36import libevdev
37
38# minimum width of a field in the table
39MIN_FIELD_WIDTH = 6
40
41
42# Default is to just return the value of an axis, but some axes want special
43# formatting.
44def format_value(code, value):
45    if code in (libevdev.EV_ABS.ABS_MISC, libevdev.EV_MSC.MSC_SERIAL):
46        return f"{value & 0xFFFFFFFF:#x}"
47
48    # Rel axes we always print the sign
49    if code.type == libevdev.EV_REL:
50        return f"{value:+d}"
51
52    return f"{value}"
53
54
55# The list of axes we want to track
56def is_tracked_axis(code, allowlist, denylist):
57    if code.type in (libevdev.EV_KEY, libevdev.EV_SW, libevdev.EV_SYN):
58        return False
59
60    # We don't do slots in this tool
61    if code.type == libevdev.EV_ABS:
62        if libevdev.EV_ABS.ABS_MT_SLOT <= code <= libevdev.EV_ABS.ABS_MAX:
63            return False
64
65    if allowlist:
66        return code in allowlist
67    else:
68        return code not in denylist
69
70
71def main(argv):
72    parser = argparse.ArgumentParser(
73        description="Display a recording in a tabular format"
74    )
75    parser.add_argument(
76        "path", metavar="recording", nargs=1, help="Path to libinput-record YAML file"
77    )
78    parser.add_argument(
79        "--ignore",
80        metavar="ABS_X,ABS_Y,...",
81        default="",
82        help="A comma-separated list of axis names to ignore",
83    )
84    parser.add_argument(
85        "--only",
86        metavar="ABS_X,ABS_Y,...",
87        default="",
88        help="A comma-separated list of axis names to print, ignoring all others",
89    )
90    parser.add_argument(
91        "--print-state",
92        action="store_true",
93        default=False,
94        help="Always print all axis values, even unchanged ones",
95    )
96
97    args = parser.parse_args()
98    if args.ignore and args.only:
99        print("Only one of --ignore and --only may be given", file=sys.stderr)
100        sys.exit(2)
101
102    ignored_axes = [libevdev.evbit(axis) for axis in args.ignore.split(",") if axis]
103    only_axes = [libevdev.evbit(axis) for axis in args.only.split(",") if axis]
104
105    isatty = os.isatty(sys.stdout.fileno())
106
107    yml = yaml.safe_load(open(args.path[0]))
108    if yml["ndevices"] > 1:
109        print(f"WARNING: Using only first {yml['ndevices']} devices in recording")
110    device = yml["devices"][0]
111
112    if not device["events"]:
113        print(f"No events found in recording")
114        sys.exit(1)
115
116    def events():
117        """
118        Yields the next event in the recording
119        """
120        for event in device["events"]:
121            for evdev in event.get("evdev", []):
122                yield libevdev.InputEvent(
123                    code=libevdev.evbit(evdev[2], evdev[3]),
124                    value=evdev[4],
125                    sec=evdev[0],
126                    usec=evdev[1],
127                )
128
129    def interesting_axes(events):
130        """
131        Yields the libevdev codes with the axes in this recording
132        """
133        used_axes = []
134        for e in events:
135            if e.code not in used_axes and is_tracked_axis(
136                e.code, only_axes, ignored_axes
137            ):
138                yield e.code
139                used_axes.append(e.code)
140
141    # Compile all axes that we want to print first
142    axes = sorted(
143        interesting_axes(events()), key=lambda x: x.type.value * 1000 + x.value
144    )
145    # Strip the REL_/ABS_ prefix for the headers
146    headers = [a.name[4:].rjust(MIN_FIELD_WIDTH) for a in axes]
147    # for easier formatting later, we keep the header field width in a dict
148    axes = {a: len(h) for a, h in zip(axes, headers)}
149
150    # Time is a special case, always the first entry
151    # Format uses ms only, we rarely ever care about µs
152    headers = [f"{'Time':<7s}"] + headers + ["Keys"]
153    header_line = f"{' | '.join(headers)}"
154    print(header_line)
155    print("-" * len(header_line))
156
157    current_codes = []
158    current_frame = {}  # {evdev-code: value}
159    axes_in_use = {}  # to print axes never sending events
160    last_fields = []  # to skip duplicate lines
161    continuation_count = 0
162
163    keystate = {}
164    keystate_changed = False
165
166    for e in events():
167        axes_in_use[e.code] = True
168
169        if e.code.type == libevdev.EV_KEY:
170            keystate[e.code] = e.value
171            keystate_changed = True
172        elif is_tracked_axis(e.code, only_axes, ignored_axes):
173            current_frame[e.code] = e.value
174            current_codes.append(e.code)
175        elif e.code == libevdev.EV_SYN.SYN_REPORT:
176            fields = []
177            for a in axes:
178                if args.print_state or a in current_codes:
179                    s = format_value(a, current_frame.get(a, 0))
180                else:
181                    s = ""
182                fields.append(s.rjust(max(MIN_FIELD_WIDTH, axes[a])))
183            current_codes = []
184
185            if last_fields != fields or keystate_changed:
186                last_fields = fields.copy()
187                keystate_changed = False
188
189                if continuation_count:
190                    if not isatty:
191                        print(f" ... +{continuation_count}", end="")
192                    print("")
193                    continuation_count = 0
194
195                fields.insert(0, f"{e.sec: 3d}.{e.usec//1000:03d}")
196                keys_down = [k.name for k, v in keystate.items() if v]
197                fields.append(", ".join(keys_down))
198                print(" | ".join(fields))
199            else:
200                continuation_count += 1
201                if isatty:
202                    print(f"\r ... +{continuation_count}", end="", flush=True)
203
204    # Print out any rel/abs axes that not generate events in
205    # this recording
206    unused_axes = []
207    for evtype, evcodes in device["evdev"]["codes"].items():
208        for c in evcodes:
209            code = libevdev.evbit(int(evtype), int(c))
210            if (
211                is_tracked_axis(code, only_axes, ignored_axes)
212                and code not in axes_in_use
213            ):
214                unused_axes.append(code)
215
216    if unused_axes:
217        print(
218            f"Axes present but without events: {', '.join([a.name for a in unused_axes])}"
219        )
220
221    for e in events():
222        if libevdev.EV_ABS.ABS_MT_SLOT <= code <= libevdev.EV_ABS.ABS_MAX:
223            print(
224                "WARNING: This recording contains multitouch data that is not supported by this tool."
225            )
226            break
227
228
229if __name__ == "__main__":
230    try:
231        main(sys.argv)
232    except BrokenPipeError:
233        pass
234