#!/usr/bin/env python3 """watch_target.py -- prove what the target actually received, on the target. Run this on the machine the keyrelay board is plugged into. It opens the evdev node of the board's keyboard interface and decodes every event, so verification never depends on what happens to have focus: no terminal, no editor, no guessed timing. python3 watch_target.py # 20 s, then a summary python3 watch_target.py --seconds 60 python3 watch_target.py --show-all # include unknown key codes It reads, it does not write: EV_KEY and EV_LED only, and nothing is injected. Membership of the `input` group is enough to open /dev/input/event*. The board is found by USB id rather than by a device-name path, so a rename or a different port cannot break it (the default id is the Logitech G815 identity the firmware uses, see src/main.cpp; the Espressif default is also accepted for older builds). """ from __future__ import annotations import argparse import glob import select import struct import sys import time EVENT_FORMAT = "llHHi" # struct input_event on 64-bit Linux EVENT_SIZE = struct.calcsize(EVENT_FORMAT) EV_SYN, EV_KEY, EV_LED = 0x00, 0x01, 0x11 IDENTITIES = {("046d", "c33f"), ("303a", "1001")} # evdev key code -> label. Covers everything keyrelay can put on the wire, from the # modifier byte through the navigation cluster and the numpad. KEY_NAMES = { 1: "Esc", 2: "1", 3: "2", 4: "3", 5: "4", 6: "5", 7: "6", 8: "7", 9: "8", 10: "9", 11: "0", 12: "Minus", 13: "Equal", 14: "Backspace", 15: "Tab", 16: "Q", 17: "W", 18: "E", 19: "R", 20: "T", 21: "Y", 22: "U", 23: "I", 24: "O", 25: "P", 26: "BracketLeft", 27: "BracketRight", 28: "Enter", 29: "LCtrl", 30: "A", 31: "S", 32: "D", 33: "F", 34: "G", 35: "H", 36: "J", 37: "K", 38: "L", 39: "Semicolon", 40: "Quote", 41: "Backquote", 42: "LShift", 43: "Backslash", 44: "Z", 45: "X", 46: "C", 47: "V", 48: "B", 49: "N", 50: "M", 51: "Comma", 52: "Period", 53: "Slash", 54: "RShift", 55: "KpMultiply", 56: "LAlt", 57: "Space", 58: "CapsLock", 59: "F1", 60: "F2", 61: "F3", 62: "F4", 63: "F5", 64: "F6", 65: "F7", 66: "F8", 67: "F9", 68: "F10", 69: "NumLock", 70: "ScrollLock", 71: "Kp7", 72: "Kp8", 73: "Kp9", 74: "KpMinus", 75: "Kp4", 76: "Kp5", 77: "Kp6", 78: "KpPlus", 79: "Kp1", 80: "Kp2", 81: "Kp3", 82: "Kp0", 83: "KpDecimal", 86: "IntlBackslash", 87: "F11", 88: "F12", 96: "KpEnter", 97: "RCtrl", 99: "PrintScreen", 100: "RAlt", 102: "Home", 103: "Up", 104: "PageUp", 105: "Left", 106: "Right", 107: "End", 108: "Down", 109: "PageDown", 110: "Insert", 111: "Delete", 119: "Pause", 125: "LSuper", 126: "RSuper", 127: "ContextMenu", 158: "KpLeftParen", 159: "KpRightParen", # HID usages 0x68..0x73. Unnamed at first, which hid a whole verification run: # --show-all existed but the F13..F24 row people actually test with was missing. 183: "F13", 184: "F14", 185: "F15", 186: "F16", 187: "F17", 188: "F18", 189: "F19", 190: "F20", 191: "F21", 192: "F22", 193: "F23", 194: "F24", } # code -> (unshifted, shifted), for reassembling what was actually typed. Anything # not listed still shows up as a named key press, just not as a character. TYPED = { 57: (" ", " "), 2: ("1", "!"), 3: ("2", "@"), 4: ("3", "#"), 5: ("4", "$"), 6: ("5", "%"), 7: ("6", "^"), 8: ("7", "&"), 9: ("8", "*"), 10: ("9", "("), 11: ("0", ")"), 12: ("-", "_"), 13: ("=", "+"), 26: ("[", "{"), 27: ("]", "}"), 39: (";", ":"), 40: ("'", '"'), 41: ("`", "~"), 43: ("\\", "|"), 51: (",", "<"), 52: (".", ">"), 53: ("/", "?"), 86: ("\\", "|"), 15: ("\t", "\t"), 28: ("\n", "\n"), } for _code, _letter in [(16, "q"), (17, "w"), (18, "e"), (19, "r"), (20, "t"), (21, "y"), (22, "u"), (23, "i"), (24, "o"), (25, "p"), (30, "a"), (31, "s"), (32, "d"), (33, "f"), (34, "g"), (35, "h"), (36, "j"), (37, "k"), (38, "l"), (44, "z"), (45, "x"), (46, "c"), (47, "v"), (48, "b"), (49, "n"), (50, "m")]: TYPED[_code] = (_letter, _letter.upper()) SHIFTS = {42, 54} CONTROLS = {29, 97, 56, 100, 125, 126} # Linux EV_LED indices are ZERO-based (LED_NUML 0, LED_CAPSL 1, LED_SCROLLL 2 ...), # which is one less than the HID/USB LED bit positions the firmware reports -- the # first version of this table was copied from the HID numbering and labelled every # LED one along. Verified against real events: a CapsLock toggle reports code 1. LED_NAMES = {0: "NumLock", 1: "CapsLock", 2: "ScrollLock", 3: "Compose", 4: "Kana", 5: "Sleep", 6: "Suspend"} # Sequences worth calling out by name instead of as three separate key presses: # these are the ones whose whole meaning is the combination. CHORDS = { frozenset({29, 56, 111}): "Ctrl+Alt+Del", frozenset({29, 56, 61}): "Ctrl+Alt+F3 (VT switch)", frozenset({29, 56, 59}): "Ctrl+Alt+F1 (VT switch)", frozenset({29, 56, 60}): "Ctrl+Alt+F2 (VT switch)", frozenset({29, 56, 62}): "Ctrl+Alt+F4 (VT switch)", frozenset({29, 56, 70}): "Ctrl+Alt+ScrollLock (SysRq reboot path)", frozenset({125, 56}): "Super+Alt", } def read_sysfs(path: str) -> str: try: with open(path) as handle: return handle.read().strip() except OSError: return "" def keyboard_nodes() -> list[str]: """evdev nodes of every USB device in IDENTITIES.""" nodes: list[str] = [] for device in glob.glob("/sys/bus/usb/devices/*"): if (read_sysfs(f"{device}/idVendor"), read_sysfs(f"{device}/idProduct")) not in IDENTITIES: continue # Depth is spelled out on purpose: a recursive ** glob over sysfs stalls. found = glob.glob(f"{device}/*/input/input*/event*") found += glob.glob(f"{device}/*/*/input/input*/event*") nodes += ["/dev/input/" + n.rsplit("/", 1)[-1] for n in sorted(found)] return sorted(set(nodes)) def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--seconds", type=float, default=20.0) ap.add_argument("--show-all", action="store_true", help="also print key codes this tool cannot name (unknown codes " "are counted, never dropped silently: they show in totals)") args = ap.parse_args() nodes = keyboard_nodes() if not nodes: ids = " or ".join(":".join(i) for i in IDENTITIES) print(f"no keyboard for {ids} under /sys/bus/usb -- is the board plugged in " f"and enumerated here?", file=sys.stderr) return 1 if len(nodes) > 1: print(f"note: several matching nodes {nodes}; watching the first") path = nodes[0] print(f"watching {path} for {args.seconds:.0f} s -- press keys on the controller") print(f"{'t':>9} event") held: set[int] = set() typed: list[str] = [] presses = 0 chords_seen: set[str] = set() reported_chords: set[str] = set() shift = False start: float | None = None errors = 0 with open(path, "rb", buffering=0) as handle: deadline = time.time() + args.seconds while time.time() < deadline: remaining = max(0.0, deadline - time.time()) if not select.select([handle], [], [], remaining)[0]: continue raw = handle.read(EVENT_SIZE) if raw is None or len(raw) != EVENT_SIZE: errors += 1 continue sec, usec, event_type, code, value = struct.unpack(EVENT_FORMAT, raw) if start is None: start = sec + usec / 1e6 stamp = (sec + usec / 1e6) - start if event_type == EV_LED: name = LED_NAMES.get(code, f"LED{code}") print(f" t={stamp:6.2f}s LED {name} {'on' if value else 'off'}") continue if event_type != EV_KEY: continue name = KEY_NAMES.get(code, f"key{code}") if name.startswith("key") and not args.show_all: continue if value == 1: # press presses += 1 held.add(code) if code in SHIFTS: shift = True current = frozenset(held) for combo, label in CHORDS.items(): if combo <= current and label not in reported_chords: reported_chords.add(label) chords_seen.add(label) print(f" t={stamp:6.2f}s >>> {label} <<<") if code in TYPED and not (current & CONTROLS): typed.append(TYPED[code][1 if shift else 0]) elif code in TYPED and code == 28: typed.append("") state = "press" elif value == 0: # release held.discard(code) if code in SHIFTS and not (held & SHIFTS): shift = False state = "release" else: # autorepeat state = "repeat" print(f" t={stamp:6.2f}s {name:>16} {state}", flush=True) print() print(f" key presses {presses}") if chords_seen: print(f" chords {', '.join(sorted(chords_seen))}") if typed: text = "".join(typed) preview = text if len(text) <= 240 else text[:240] + "..." print(f" typed {preview!r}") else: print(" typed (nothing plain-text -- modifiers or chords only)") if held: print(f" still held {', '.join(KEY_NAMES.get(c, str(c)) for c in held)}" " <-- a stuck key: check the controller released it") if errors: print(f" short reads {errors}") return 0 if __name__ == "__main__": sys.exit(main())