#!/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 nodes of the board's HID interfaces 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 python3 watch_target.py --keyboard-only # ignore the mouse interface The board enumerates as a keyboard and a relative mouse on one HID interface, which the kernel splits into two input devices, so both are watched at once. "The mouse works" means *the target received REL_X*, and a keyboard watcher cannot see that. Movement is coalesced into one line per report -- a captured pointer is 125 reports a second, and a stream nobody can read proves nothing -- while buttons and wheel are printed as they happen. The summary reports distance travelled, not event counts, so it can be compared against a movement you actually made. It reads, it does not write: EV_KEY, EV_REL 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 pathlib 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_REL, EV_ABS, EV_LED = 0x00, 0x01, 0x02, 0x03, 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", } # Mouse button codes. A separate number space from the keyboard's EV_KEY codes above -- # which is exactly why the two interfaces are decoded separately instead of through one # table: BTN_LEFT is 272 and no key code is anywhere near it. BTN_NAMES = {272: "Left", 273: "Right", 274: "Middle", 275: "Back", 276: "Forward", 277: "Task", 278: "WheelLeft", 279: "WheelRight"} # Input-event axis codes. The first version of this table had the wheel at code 2, guessed # from the order the HID report lays its bytes out -- and REL_WHEEL is 8. It was caught by # nothing on the host and by everything on the target: `mouse wheel -3` scrolled the # machine, the watcher printed nothing, and the summary said no wheel happened. So the # numbers are pinned to by name and the unknown-code path is # loud, because a verifier that quietly drops a code is worse than one that is wrong. REL_X, REL_Y, REL_Z = 0, 1, 2 REL_HWHEEL, REL_DIAL, REL_WHEEL, REL_MISC = 6, 7, 8, 9 REL_WHEEL_HI_RES, REL_HWHEEL_HI_RES = 11, 12 REL_NAMES = {REL_X: "REL_X", REL_Y: "REL_Y", REL_Z: "REL_Z", REL_HWHEEL: "REL_HWHEEL", REL_DIAL: "REL_DIAL", REL_WHEEL: "REL_WHEEL", REL_MISC: "REL_MISC", REL_WHEEL_HI_RES: "REL_WHEEL_HI_RES", REL_HWHEEL_HI_RES: "REL_HWHEEL_HI_RES"} # 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 capability_bits(path: str) -> int: """The EV_* bitmask a device supports, from sysfs. 0 when it cannot be read.""" value = 0 for word in read_sysfs(path).split(): try: value = (value << 32) | int(word, 16) except ValueError: return 0 return value def classify(ev_bits: int) -> str | None: """Which of the two devices a node is, from its EV_* capability bits. Split out of target_nodes() so the rule itself can be tested: a relative pointing device is the one that advertises EV_REL, and the keyboard is everything else that advertises EV_KEY. Nothing here looks at a device *name*, because the name is what changes when a different driver binds to it. """ if ev_bits & (1 << EV_REL): return "mouse" if ev_bits & (1 << EV_KEY): return "keyboard" return None def target_nodes(want_mouse: bool = True) -> dict[str, str]: """evdev nodes of the board's interfaces, keyed "keyboard" / "mouse". Classified by what the kernel says the device emits, not by its name or by the order the glob found them in. Watching "the first matching node" would have watched the mouse and reported the keyboard's keystrokes as missing, or the other way round, depending on which interface the kernel probed first -- which is not a fact to build a verification on. """ out: dict[str, 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*") for path in sorted(found): # .../input/inputN/eventM -> .../input/inputN/capabilities/ev caps = pathlib.Path(path).parent / "capabilities" / "ev" kind = classify(capability_bits(str(caps))) if kind is None or (kind == "mouse" and not want_mouse): continue out.setdefault(kind, "/dev/input/" + path.rsplit("/", 1)[-1]) return out class KeyboardWatch: """Key presses, chords, LEDs, and the text that came out of them.""" kind = "keyboard" def __init__(self, show_all: bool) -> None: self.show_all = show_all self.held: set[int] = set() self.typed: list[str] = [] self.presses = 0 self.chords: set[str] = set() self.reported: set[str] = set() self.shift = False def event(self, stamp: float, code: int, value: int) -> str | None: if code not in KEY_NAMES and not self.show_all: return None # unknown codes are counted, never dropped: if code not in KEY_NAMES: # they still show up in --show-all totals name = f"key{code}" else: name = KEY_NAMES[code] if value == 1: # press self.presses += 1 self.held.add(code) if code in SHIFTS: self.shift = True current = frozenset(self.held) for combo, label in CHORDS.items(): if combo <= current and label not in self.reported: self.reported.add(label) self.chords.add(label) print(f" t={stamp:6.2f}s >>> {label} <<<") if code in TYPED and not (current & CONTROLS): self.typed.append(TYPED[code][1 if self.shift else 0]) elif code in TYPED and code == 28: self.typed.append("") state = "press" elif value == 0: # release self.held.discard(code) if code in SHIFTS and not (self.held & SHIFTS): self.shift = False state = "release" else: # autorepeat state = "repeat" return f"{name:>16} {state}" def led(self, stamp: float, code: int, value: int) -> str: return f"LED {LED_NAMES.get(code, f'LED{code}')} {'on' if value else 'off'}" def flush(self, stamp: float) -> str | None: """Keyboards have nothing to coalesce; the interface is here so the loop is.""" return None def report(self) -> list[str]: lines = [f" key presses {self.presses}"] if self.chords: lines.append(f" chords {', '.join(sorted(self.chords))}") if self.typed: text = "".join(self.typed) preview = text if len(text) <= 240 else text[:240] + "..." lines.append(f" typed {preview!r}") else: lines.append(" typed (nothing plain-text -- modifiers or chords " "only)") if self.held: lines.append(" still held " + ", ".join(KEY_NAMES.get(c, str(c)) for c in self.held) + " <-- a stuck key: check the controller released it") return lines class MouseWatch: """Relative movement, coalesced per report, plus buttons and wheel.""" kind = "mouse" def __init__(self, show_all: bool) -> None: self.show_all = show_all self.reports = 0 # reports that carried movement self.x = 0 # net travel, the thing you can compare with a ruler self.y = 0 self.travel = 0 # total path length in device units self.wheel = 0 self.hpan = 0 self.held: set[int] = set() self.clicks = 0 self.seen_buttons: set[str] = set() self.axes: set[str] = set() self._dx = 0 # the report being accumulated self._dy = 0 self._hw = 0 def event(self, stamp: float, code: int, value: int) -> str | None: if code in REL_NAMES: name = REL_NAMES[code] self.axes.add(name) if code == REL_X: self._dx += value elif code == REL_Y: self._dy += value elif code == REL_WHEEL: self.wheel += value return f"{name:>16} {value:+d}" elif code == REL_HWHEEL: self.hpan += value return f"{name:>16} {value:+d}" # Hi-res wheel events are 1/240 of a detent and repeat the notch the plain # event already reported; counting them would multiply the scroll by 240. return None if code in BTN_NAMES: name = BTN_NAMES[code] self.seen_buttons.add(name) if value == 1: self.held.add(code) self.clicks += 1 return f"BTN {name:>12} press" if value == 0: self.held.discard(code) return f"BTN {name:>12} release" return None # An axis this tool has no name for is still counted and still shown -- silently # dropping it is how a working wheel looked like a dead one. self.axes.add(f"rel{code}") if not self.show_all: return None return f"{'rel' + str(code):>16} {value:+d}" def flush(self, stamp: float) -> str | None: """One line per report that moved: 125 reports a second is a blur otherwise.""" if not (self._dx or self._dy): return None self.reports += 1 self.x += self._dx self.y += self._dy self.travel += abs(self._dx) + abs(self._dy) line = f"{'movement':>16} dx {self._dx:+5d} dy {self._dy:+5d}" self._dx = self._dy = 0 return line def led(self, stamp: float, code: int, value: int) -> str | None: return None # pointing devices have no LEDs def report(self) -> list[str]: lines = [f" reports moved {self.reports}"] if self.reports: lines.append(f" net position dx {self.x:+d} dy {self.y:+d} " f"(path length {self.travel})") else: lines.append(" net position (no movement reached the target)") if self.axes: lines.append(f" axes seen {', '.join(sorted(self.axes))}") lines.append(f" wheel {self.wheel:+d} notches, {self.hpan:+d} horizontal") if self.seen_buttons: lines.append(f" buttons {', '.join(sorted(self.seen_buttons))} " f"({self.clicks} press(es))") else: lines.append(" buttons (none -- try `relayctl mouse click --yes`)") if self.held: lines.append(" still held " + ", ".join(BTN_NAMES.get(c, str(c)) for c in self.held) + " <-- a stuck button: the controller let go of it and the " "board did not") return lines WATCHERS = {"keyboard": KeyboardWatch, "mouse": MouseWatch} 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)") ap.add_argument("--keyboard-only", action="store_true", help="ignore the mouse interface -- for a board flashed with " "`mouse off`, or to read the keyboard without the pointer " "filling the screen") args = ap.parse_args() nodes = target_nodes(want_mouse=not args.keyboard_only) 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 missing = [k for k in ("keyboard", "mouse") if k not in nodes and not (k == "mouse" and args.keyboard_only)] for kind in missing: print(f"note: no {kind} interface found -- the board enumerated without it " f"(`mouse off` over serial does that deliberately)", file=sys.stderr) handles = {} for kind, path in sorted(nodes.items()): try: handles[kind] = open(path, "rb", buffering=0) except OSError as exc: print(f"cannot open {path} for the {kind}: {exc} -- are you in the `input` " f"group?", file=sys.stderr) return 1 watchers = {kind: WATCHERS[kind](args.show_all) for kind in handles} print(f"watching {', '.join(f'{k}={v}' for k, v in sorted(nodes.items()))} " f"for {args.seconds:.0f} s") print("press keys, or capture the pointer in the controller page and move it") print(f"{'t':>9} event") errors = 0 start: float | None = None try: deadline = time.time() + args.seconds while time.time() < deadline: remaining = max(0.0, deadline - time.time()) ready, _, _ = select.select(list(handles.values()), [], [], remaining) if not ready: continue for kind, handle in handles.items(): if handle not in ready: 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 watcher = watchers[kind] if event_type == EV_SYN: line = watcher.flush(stamp) if line: print(f" t={stamp:6.2f}s {line}", flush=True) continue if event_type == EV_LED: line = watcher.led(stamp, code, value) if line: print(f" t={stamp:6.2f}s {line}", flush=True) continue if event_type == EV_KEY: line = watcher.event(stamp, code, value) elif event_type == EV_REL: line = watcher.event(stamp, code, value) else: continue if line: print(f" t={stamp:6.2f}s {line}", flush=True) finally: for handle in handles.values(): handle.close() print() for kind in ("keyboard", "mouse"): if kind in watchers: print(f" --- {kind} ---") for line in watchers[kind].report(): print(line) if errors: print(f" short reads {errors}") return 0 if __name__ == "__main__": sys.exit(main())