Files
keyrelay/tools/watch_target.py
T
ducoterra ece0313d02 feat: relative mouse on the HID interface, and the verifier to prove it
The board now enumerates as a boot keyboard plus a relative mouse on the same
HID interface. The pointer is captured, never grabbed: Pointer Lock in the
page, so Esc (the browser's own, never forwarded) always frees it, and every
frame restates the whole button byte so a frame lost in the WiFi cannot leave
a button held. Losing the lock, the tab or the link runs the same release
funnel as a stuck key, and boot protocol or `mouse off` refuses mouse frames
with E11 rather than sending bytes a boot interface would misparse as keys.

src/mouse.h owns the report, src/output.h the queues and the funnel; the page,
relayctl.py and watch_target.py carry the button bits and the evdev decoding.
The verifier's own tables are pinned now — REL_WHEEL is 8, not 2, and the
first version guessed from the report's field order, so a working wheel looked
dead while the summary said no scroll happened.

LLM-Generated: true
2026-11-08 23:27:42 -05:00

460 lines
20 KiB
Python

#!/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 <linux/input-event-codes.h> 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("<Enter-with-modifiers>")
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())