Files
keyrelay/tools/watch_target.py
T
ducoterra 9bc8d5213c Initial import: keyrelay, a WiFi-controlled USB HID keyboard
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
2026-11-08 00:20:33 -05:00

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())