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
This commit is contained in:
+318
-83
@@ -1,25 +1,35 @@
|
||||
#!/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.
|
||||
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 # 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 --show-all # include unknown key codes
|
||||
python3 watch_target.py --keyboard-only # ignore the mouse interface
|
||||
|
||||
It reads, it does not write: EV_KEY and EV_LED only, and nothing is injected.
|
||||
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).
|
||||
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
|
||||
@@ -28,7 +38,7 @@ 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
|
||||
EV_SYN, EV_KEY, EV_REL, EV_ABS, EV_LED = 0x00, 0x01, 0x02, 0x03, 0x11
|
||||
|
||||
IDENTITIES = {("046d", "c33f"), ("303a", "1001")}
|
||||
|
||||
@@ -59,6 +69,27 @@ KEY_NAMES = {
|
||||
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 = {
|
||||
@@ -106,9 +137,42 @@ def read_sysfs(path: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def keyboard_nodes() -> list[str]:
|
||||
"""evdev nodes of every USB device in IDENTITIES."""
|
||||
nodes: list[str] = []
|
||||
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:
|
||||
@@ -116,8 +180,183 @@ def keyboard_nodes() -> list[str]:
|
||||
# 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))
|
||||
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:
|
||||
@@ -127,94 +366,90 @@ def main() -> int:
|
||||
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 = keyboard_nodes()
|
||||
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
|
||||
if len(nodes) > 1:
|
||||
print(f"note: several matching nodes {nodes}; watching the first")
|
||||
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)
|
||||
|
||||
path = nodes[0]
|
||||
print(f"watching {path} for {args.seconds:.0f} s -- press keys on the controller")
|
||||
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")
|
||||
|
||||
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
|
||||
start: float | None = None
|
||||
|
||||
with open(path, "rb", buffering=0) as handle:
|
||||
try:
|
||||
deadline = time.time() + args.seconds
|
||||
while time.time() < deadline:
|
||||
remaining = max(0.0, deadline - time.time())
|
||||
if not select.select([handle], [], [], remaining)[0]:
|
||||
ready, _, _ = select.select(list(handles.values()), [], [], remaining)
|
||||
if not 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
|
||||
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_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("<Enter-with-modifiers>")
|
||||
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)
|
||||
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()
|
||||
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")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user