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
460 lines
20 KiB
Python
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())
|