#!/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