diff --git a/.gitlab-ci/libinput.spec.in b/.gitlab-ci/libinput.spec.in index e3872f9c..bd0d30dc 100644 --- a/.gitlab-ci/libinput.spec.in +++ b/.gitlab-ci/libinput.spec.in @@ -111,6 +111,7 @@ intended to be run by users. %{_libexecdir}/libinput/libinput-record %{_libexecdir}/libinput/libinput-replay %{_libexecdir}/libinput/libinput-analyze +%{_libexecdir}/libinput/libinput-analyze-buttons %{_libexecdir}/libinput/libinput-analyze-per-slot-delta %{_libexecdir}/libinput/libinput-analyze-recording %{_libexecdir}/libinput/libinput-analyze-touch-down-state @@ -129,6 +130,7 @@ intended to be run by users. %{_mandir}/man1/libinput-record.1* %{_mandir}/man1/libinput-replay.1* %{_mandir}/man1/libinput-analyze.1* +%{_mandir}/man1/libinput-analyze-buttons.1* %{_mandir}/man1/libinput-analyze-per-slot-delta.1* %{_mandir}/man1/libinput-analyze-recording.1* %{_mandir}/man1/libinput-analyze-touch-down-state.1* diff --git a/meson.build b/meson.build index 0479df14..486f6081 100644 --- a/meson.build +++ b/meson.build @@ -517,6 +517,7 @@ executable('libinput-analyze', ) src_python_tools = files( + 'tools/libinput-analyze-buttons.py', 'tools/libinput-analyze-per-slot-delta.py', 'tools/libinput-analyze-recording.py', 'tools/libinput-analyze-touch-down-state.py', @@ -990,6 +991,7 @@ endif src_man += files( 'tools/libinput.man', 'tools/libinput-analyze.man', + 'tools/libinput-analyze-buttons.man', 'tools/libinput-analyze-per-slot-delta.man', 'tools/libinput-analyze-recording.man', 'tools/libinput-analyze-touch-down-state.man', diff --git a/tools/libinput-analyze-buttons.man b/tools/libinput-analyze-buttons.man new file mode 100644 index 00000000..5e473bbb --- /dev/null +++ b/tools/libinput-analyze-buttons.man @@ -0,0 +1,26 @@ +.TH libinput-analyze-buttons "1" +.SH NAME +libinput\-analyze\-buttons \- analyze the button states of a recording +.SH SYNOPSIS +.B libinput analyze buttons [\-\-help] [options] \fIrecording.yml\fI +.SH DESCRIPTION +.PP +The +.B "libinput analyze buttons" +tool analyzes a recording made with +.B "libinput record" +and prints information about the button states. +.PP +This is a debugging tool only, its output may change at any time. Do not +rely on the output. +.SH OPTIONS +.TP 8 +.B \-\-help +Print help +.TP 8 +.B \-\-threshold= +Color any delta time less than the threshold in red. +.SH LIBINPUT +Part of the +.B libinput(1) +suite diff --git a/tools/libinput-analyze-buttons.py b/tools/libinput-analyze-buttons.py new file mode 100755 index 00000000..d33e6d2f --- /dev/null +++ b/tools/libinput-analyze-buttons.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 +# vim: set expandtab shiftwidth=4: +# -*- Mode: python; coding: utf-8; indent-tabs-mode: nil -*- */ +# +# Copyright © 2024 Red Hat, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the 'Software'), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice (including the next +# paragraph) shall be included in all copies or substantial portions of the +# Software. +# +# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. +# +# Prints the data from a libinput recording in a table format to ease +# debugging. +# +# Input is a libinput record yaml file + +from dataclasses import dataclass +import argparse +import os +import sys +import yaml +import libevdev + +COLOR_RESET = "\x1b[0m" +COLOR_RED = "\x1b[6;31m" + + +def micros(e: libevdev.InputEvent): + return e.usec + e.sec * 1_000_000 + + +@dataclass +class Timestamp: + sec: int + usec: int + + @property + def micros(self) -> int: + return self.usec + self.sec * 1_000_000 + + +@dataclass +class ButtonFrame: + delta_ms: int # delta time to last button (not evdev!) frame + evdev_delta_ms: int # delta time to last evdev frame + events: list[libevdev.InputEvent] # BTN_ events only + + @property + def timestamp(self) -> Timestamp: + e = self.events[0] + return Timestamp(e.sec, e.usec) + + def value(self, code: libevdev.EventCode) -> bool | None: + for e in self.events: + if e.matches(code): + return e.value + return None + + def values(self, codes: list[libevdev.EventCode]) -> list[bool | None]: + return [self.value(code) for code in codes] + + +def frames(events): + last_timestamp = None + current_frame = None + last_frame = None + for e in events: + if last_timestamp is None: + last_timestamp = micros(e) + if e.type == libevdev.EV_SYN: + last_timestamp = micros(e) + if current_frame is not None: + yield current_frame + last_frame = current_frame + current_frame = None + elif e.type == libevdev.EV_KEY: + if e.code.name.startswith("BTN_") and not e.code.name.startswith( + "BTN_TOOL_" + ): + timestamp = micros(e) + evdev_delta = (timestamp - last_timestamp) // 1000 + + if last_frame is not None: + delta = (timestamp - last_frame.timestamp.micros) // 1000 + else: + delta = 0 + + if current_frame is None: + current_frame = ButtonFrame( + delta_ms=delta, evdev_delta_ms=evdev_delta, events=[e] + ) + else: + current_frame.events.append(e) + + +def main(argv): + parser = argparse.ArgumentParser(description="Display button events in a recording") + parser.add_argument( + "--threshold", + type=int, + default=25, + help="Mark any time delta above this threshold (in ms)", + ) + parser.add_argument( + "path", metavar="recording", nargs=1, help="Path to libinput-record YAML file" + ) + args = parser.parse_args() + isatty = os.isatty(sys.stdout.fileno()) + if not isatty: + global COLOR_RESET + global COLOR_RED + COLOR_RESET = "" + COLOR_RED = "" + + yml = yaml.safe_load(open(args.path[0])) + if yml["ndevices"] > 1: + print(f"WARNING: Using only first {yml['ndevices']} devices in recording") + device = yml["devices"][0] + if not device["events"]: + print("No events found in recording") + sys.exit(1) + + def events(): + """ + Yields the next event in the recording + """ + for event in device["events"]: + for evdev in event.get("evdev", []): + yield libevdev.InputEvent( + code=libevdev.evbit(evdev[2], evdev[3]), + value=evdev[4], + sec=evdev[0], + usec=evdev[1], + ) + + # These are the buttons we possibly care about, but we filter to the ones + # found on this device anyway + buttons = [ + libevdev.EV_KEY.BTN_LEFT, + libevdev.EV_KEY.BTN_MIDDLE, + libevdev.EV_KEY.BTN_RIGHT, + libevdev.EV_KEY.BTN_SIDE, + libevdev.EV_KEY.BTN_EXTRA, + libevdev.EV_KEY.BTN_FORWARD, + libevdev.EV_KEY.BTN_BACK, + libevdev.EV_KEY.BTN_TASK, + libevdev.EV_KEY.BTN_TOUCH, + libevdev.EV_KEY.BTN_STYLUS, + libevdev.EV_KEY.BTN_STYLUS2, + libevdev.EV_KEY.BTN_STYLUS3, + libevdev.EV_KEY.BTN_0, + libevdev.EV_KEY.BTN_1, + libevdev.EV_KEY.BTN_2, + libevdev.EV_KEY.BTN_3, + libevdev.EV_KEY.BTN_4, + libevdev.EV_KEY.BTN_5, + libevdev.EV_KEY.BTN_6, + libevdev.EV_KEY.BTN_7, + libevdev.EV_KEY.BTN_8, + libevdev.EV_KEY.BTN_9, + ] + + def filter_buttons(buttons): + return filter( + lambda c: c in buttons, + map(lambda c: libevdev.evbit("EV_KEY", c), device["evdev"]["codes"][1]), + ) + + buttons = list(filter_buttons(buttons)) + + # all BTN_STYLUS will have a header of S - meh + btn_headers = " │ ".join(b.name[4] for b in buttons) + print(f"{'Timestamp':^13s} │ {'Delta':^8s} │ {btn_headers}") + last_btn_vals = [None] * len(buttons) + + def btnchar(b, last): + if b == 1: + return "┬" + if b == 0: + return "┴" + return "│" if last else " " + + for frame in frames(events()): + ts = frame.timestamp + if frame.timestamp.micros > 0 and frame.delta_ms < args.threshold: + color = COLOR_RED + else: + color = "" + btn_vals = frame.values(buttons) + btn_strs = " │ ".join( + [btnchar(b, last) for b, last in zip(btn_vals, last_btn_vals)] + ) + + last_btn_vals = [ + b if b is not None else last for b, last in zip(btn_vals, last_btn_vals) + ] + + print( + f"{color}{ts.sec:6d}.{ts.usec:06d} │ {frame.delta_ms:6d}ms │ {btn_strs}{COLOR_RESET}" + ) + + +if __name__ == "__main__": + try: + main(sys.argv) + except BrokenPipeError: + pass diff --git a/tools/libinput-analyze.man b/tools/libinput-analyze.man index bd2a83da..919afe97 100644 --- a/tools/libinput-analyze.man +++ b/tools/libinput-analyze.man @@ -22,6 +22,9 @@ Print help .SH FEATURES Features that can be analyzed include .TP 8 +.B libinput\-analyze\-buttons(1) +analyze the button states of a recording +.TP 8 .B libinput\-analyze\-per-slot-delta(1) analyze the delta per event per slot .TP 8