Browser or terminal controller drives an ESP32-S3 that acts as a Logitech G815 into a target machine running no software. Disarmed by default; arming requires the passphrase, the board button, or the serial console, and every fail-safe path cancels in-flight work. - src/: firmware with pure, host-testable modules (keystate, proto, text, ws_codec, sha1), the WebSocket control server, and the served page - tests/: host suites for the pure modules, relayctl parity, and page JS - tools/: relayctl terminal controller, serial console, bootloader helper, and an evdev target watcher - platformio.ini: bridge (USB HID) and console (no-HID) environments - README.md: single source of truth for behaviour, protocol and safety LLM-Generated: true
225 lines
9.8 KiB
Python
225 lines
9.8 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 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("<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)
|
|
|
|
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())
|