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:
2026-11-08 23:27:42 -05:00
parent 9bc8d5213c
commit ece0313d02
19 changed files with 3135 additions and 324 deletions
+206 -3
View File
@@ -201,8 +201,10 @@ config_src = (root / "src" / "config.h").read_text()
def config_value(name):
found = re.search(rf"\b{name}\s*=\s*(\d+)\s*;", config_src)
return int(found.group(1)) if found else None
# Decimal or hex: kMouseDeltaMax is 127 and kMouseButtonMask is 0x1F, and both are
# numbers a client has to agree with the firmware about.
found = re.search(rf"\b{name}\s*=\s*(0[xX][0-9a-fA-F]+|\d+)\s*;", config_src)
return int(found.group(1), 0) if found else None
check_eq(relayctl.TEXT_FRAME_MAX, config_value("kTextFrameMax"),
@@ -228,6 +230,89 @@ src_lines = inspect.getsource(drained)
check("OP_PONG" in src_lines, "_drain answers a ping")
check("raise RelayError" in src_lines, "_drain still surfaces an error frame")
print("mouse")
check_eq(relayctl.mouse_frame(0x01, 30, -4), "M=01:30:-4:0:0",
"a mouse frame is the button byte then four movements")
check_eq(relayctl.mouse_frame(0), "M=00:0:0:0:0",
"and all five fields are always written -- the firmware parses exactly five")
check_eq(relayctl.mouse_frame(0x1f, -127, 127, -127, 127), "M=1f:-127:127:-127:127",
"the full signed range")
for bad, why in (((0x20, 0, 0, 0, 0), "a sixth button bit has nowhere to go"),
((0, 128, 0, 0, 0), "128 is more than one report"),
((0, -128, 0, 0, 0), "and so is -128"),
((0, 0, 0, 128, 0), "the wheel is one byte too")):
try:
relayctl.mouse_frame(*bad)
check(False, f"mouse_frame should refuse: {why}")
except relayctl.RelayError:
check(True, f"mouse_frame refuses: {why}")
# A movement bigger than one report is split here, exactly as the page splits it, and the
# split is exact -- clamping it would move the cursor half as far as was asked and say
# nothing about it.
for dx, dy in ((300, 0), (0, -260), (300, -260), (127, 127), (1, -1), (-500, 500)):
parts = relayctl.split_moves(dx, dy)
check_eq(sum(p[0] for p in parts), dx, f"the X split of {dx},{dy} adds up exactly")
check_eq(sum(p[1] for p in parts), dy, f"and the Y of {dx},{dy}")
check(all(abs(p[0]) <= relayctl.MOUSE_DELTA_MAX and abs(p[1]) <= relayctl.MOUSE_DELTA_MAX
for p in parts), f"and no part of {dx},{dy} exceeds one report")
check_eq(relayctl.mouse_frames("click", [], "left"),
[(0x01, 0, 0, 0, 0), (0, 0, 0, 0, 0)],
"a click is the button restated down then restated up")
check_eq(relayctl.mouse_frames("release", []), [(0, 0, 0, 0, 0)],
"release restates the whole button byte as nothing")
drag = relayctl.mouse_frames("drag", ["300", "0"], "right")
check_eq(drag[0], (0x02, 0, 0, 0, 0), "a drag presses first")
check(drag[-1] == (0, 0, 0, 0, 0), "and releases last")
check(all(f[0] == 0x02 for f in drag[1:-1]),
"and every movement in between carries the button, so it is a drag and not a move")
check_eq(sum(f[1] for f in drag), 300, "and the drag travels the whole distance")
check_eq(relayctl.mouse_frames("wheel", ["-3"]), [(0, 0, 0, -3, 0)],
"wheel notches go where the report's wheel byte is")
for bad_args, why in ((("move", ["1"], "left"), "move needs dx and dy"),
(("wheel", [], "left"), "wheel needs a count"),
(("move", ["1", "2"], "thumb"), "an unknown button is refused"),
(("wiggle", [], "left"), "an unknown action is refused")):
try:
relayctl.mouse_frames(*bad_args)
check(False, f"mouse_frames should refuse: {why}")
except (relayctl.RelayError, ValueError):
check(True, f"mouse_frames refuses: {why}")
# Every frame this tool can build must be one the firmware parses. The shape is asserted
# rather than a reimplementation of the parser, because the parser itself is tested
# against the same frames in tests/test_proto.cpp.
mframe = re.compile(r"^M=[0-9a-f]{2}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}$")
built = [relayctl.mouse_frame(*f)
for f in relayctl.mouse_frames("drag", ["500", "-500", "4", "-4"], "middle")]
check(all(mframe.match(f) for f in built), f"every built frame parses ({len(built)})")
check(all(len(f) <= config_value("kWsMessageCap") for f in built),
"and every one fits in one WebSocket message")
# The numbers are the board's. If they drift, the board starts answering E6 to frames
# this tool believed were legal.
check_eq(relayctl.MOUSE_DELTA_MAX, config_value("kMouseDeltaMax"),
"relayctl MOUSE_DELTA_MAX == kMouseDeltaMax")
check_eq(relayctl.MOUSE_BUTTON_MASK, config_value("kMouseButtonMask"),
"relayctl MOUSE_BUTTON_MASK == kMouseButtonMask")
check_eq(int(re.search(r"const MOUSE_DELTA_MAX = (\d+);", page_src).group(1)),
config_value("kMouseDeltaMax"), "page MOUSE_DELTA_MAX == kMouseDeltaMax")
check_eq(int(re.search(r"const MOUSE_BUTTON_MASK = (0x[0-9a-f]+);", page_src).group(1), 16),
config_value("kMouseButtonMask"), "page MOUSE_BUTTON_MASK == kMouseButtonMask")
check_eq(int(re.search(r"const MOUSE_FRAME_MS = (\d+);", page_src).group(1)),
relayctl.MOUSE_FRAME_MS, "the page and the CLI pace mouse frames identically")
# Button bits, agreed between the page and this tool even though the page keys them by
# DOM number and this keys them by name. Getting one wrong is a clean frame that is the
# wrong button -- the Alt+Tab failure again, one layer down.
check_eq({name: bits for name, bits in relayctl.MOUSE_BUTTONS.items()},
{"left": 0x01, "right": 0x02, "middle": 0x04, "back": 0x08, "forward": 0x10},
"the button bits are the report descriptor's order")
check("0: 0x01, 2: 0x02, 1: 0x04, 3: 0x08, 4: 0x10" in page_src,
"the page maps DOM button numbers to those same bits (DOM 1 is middle, 2 is right)")
print("protocol strings")
check_eq("S=%02x:%s" % (0x0A, "04,28"), "S=0a:04,28", "state frame format")
@@ -239,10 +324,128 @@ check_eq("T" + "hunter2", "Thunter2", "auth frame format")
# has to be a deliberate edit in two places.
for command in ("A1", "A0", "R", "H", "K"):
check(f'"{command}"' in relay_src, f'relayctl sends "{command}"')
for command in ("'A'", "'R'", "'H'", "'K'", "'T'", "'S'", "'Y'", "'C'", "'P'"):
for command in ("'A'", "'R'", "'H'", "'K'", "'T'", "'S'", "'Y'", "'C'", "'P'", "'M'"):
check(command in proto_src, f"proto.h parses {command}")
check("case 'M':" in proto_src, "proto.h routes M= to the mouse parser")
# The two mouse error codes have to be in the README's list. A client that cannot look up
# what E11 means cannot tell the operator why the pointer is not moving, and this suite is
# the only place that notices when the code and the document stop agreeing.
readme_src = (root / "README.md").read_text()
for code, where in (("10 the mouse interface is off", "proto.h"),
("11 the target asked for boot protocol", "proto.h")):
check(code in proto_src, f"proto.h's error list documents {code}")
check(code.split()[0] in readme_src,
f"README's error list has code {code.split()[0]}")
check("mouse interface is off" in readme_src and "boot protocol" in readme_src,
"README explains what both mouse refusals mean, not just their numbers")
check("`M=<btn>`" in readme_src or "M=<btn>" in readme_src or "`M=`" in readme_src,
"README's wire protocol table lists the M= command")
check("if (c == 'Y' && n == 0 && out.mods == 0)" in proto_src,
"proto.h still requires a chord to press something")
print("the fail-safe wiring")
# Source pins, and deliberately honest about being source pins: control_server.cpp and
# main.cpp need a socket and a board, so nothing here runs them. What they were caught
# doing is invisible to a compiler and to every behavioural test on the host, and each of
# these three is a sentence that a later edit can quietly undo.
main_src = (root / "src" / "main.cpp").read_text()
server_src = (root / "src" / "control_server.cpp").read_text()
# The backstop must not ask whether a controller is linked: a controller that has gone
# away is exactly the case where linked() is false, which made the silence timer
# uncallable in the one situation it exists for -- the board sat armed with a key held
# forever, and read-only /status traffic kept it that way.
check("if (armed && control.controllerSeen() &&" in main_src,
"loop()'s silence disarm runs off controllerSeen(), not linked()")
check("if (armed && control.linked()" not in main_src,
"nothing gates an armed-and-silent disarm on the link still being there")
# Accepting a TCP connection is not the operator being alive.
accept = server_src[server_src.index("void ControlServer::acceptNew"):
server_src.index("size_t ControlServer::readSome")]
# Code lines only -- the function carries a comment explaining why it does NOT stamp the
# clock, and a check that flagged the comment would be worse than no check at all.
accept_code = "\n".join(l for l in accept.splitlines()
if not l.strip().startswith(("//", "*", "/*")))
check("_lastInboundMs" not in accept_code,
"acceptNew() does not refresh the controller-silence clock")
# And closing a read-only /status request must not refresh it either -- the first fix
# stamped it on every close, which held a dead controller's armed state open exactly as
# effectively as the original bug did.
close_fn = server_src[server_src.index("void ControlServer::closeLink"):
server_src.index("}", server_src.index("_onLinkChange(_ctx, false)"))]
check("if (wasLinked) _lastInboundMs = millis();" in close_fn,
"closeLink() stamps the silence clock only when a controller link is what closed")
check("const bool wasLinked = _phase == Phase::WebSocket;" in close_fn,
"closeLink() treats anything that reached the WebSocket phase as able to leave "
"keys down, not only what is still marked authenticated")
print("the target-side verifier")
# Twice now the verifier itself was the bug -- mislabelled LEDs, unnamed key codes that
# looked like lost keystrokes -- so the thing that decides what "it arrived" means gets
# tested too, and the mouse half of it especially: it is new, and a watcher that misread
# REL_X would either cry wolf or miss a mouse that worked.
import watch_target as wt # noqa: E402
# EV_REL is what makes a node a pointing device. Nothing here trusts a device name.
check_eq(wt.classify(1 << wt.EV_REL | 1 << wt.EV_KEY), "mouse", "EV_REL means mouse")
check_eq(wt.classify(1 << wt.EV_KEY | 1 << wt.EV_LED), "keyboard",
"EV_KEY without EV_REL means keyboard")
check_eq(wt.classify(1 << wt.EV_ABS), None,
"an absolute-only device is neither, and is not watched as a mouse")
check_eq(wt.capability_bits("/nonexistent/does/not/exist"), 0,
"an unreadable sysfs file is 0 rather than an exception")
m = wt.MouseWatch(False)
check(m.event(0, 0, 20) is None and m.event(0, 1, -6) is None,
"a raw axis event is not a line -- one report is one line")
check(m.event(0, 0, 15) is None, "and more movement in the same report accumulates")
line = m.flush(0.1)
check(line is not None and "+35" in line and "-6" in line,
f"the report comes out as the sum of its axes ({line!r})")
check(m.flush(0.2) is None, "a report with no movement prints nothing")
check_eq(m.x, 35, "net X is the exact sum of what the target received")
check_eq(m.y, -6, "and so is net Y")
check_eq(m.travel, 41, "and the path length counts both, not the diagonal")
# REL_WHEEL_HI_RES is 1/240 of a notch. Counting it as a notch would report 240x the
# scroll that happened, which is the same class of error as the mislabelled LEDs.
# The axis numbers, pinned to the kernel's, not to the HID report's byte order. Getting
# this wrong made a working wheel look dead -- see the comment on REL_NAMES.
check_eq((wt.REL_X, wt.REL_Y, wt.REL_WHEEL, wt.REL_HWHEEL), (0, 1, 8, 6),
"axis codes are the kernel's: REL_WHEEL is 8, not 2")
check_eq((wt.REL_WHEEL_HI_RES, wt.REL_HWHEEL_HI_RES), (11, 12),
"and the hi-res wheel codes are 11/12, not 6/7")
m2 = wt.MouseWatch(False)
check(m2.event(0, wt.REL_WHEEL, -3) is not None,
"a wheel notch is printed as it happens, not coalesced with movement")
m2.event(0, wt.REL_WHEEL_HI_RES, 720) # the same notches again, finer
check_eq(m2.wheel, -3, "hi-res wheel events are not counted as extra notches")
check("REL_WHEEL" in "\n".join(m2.report()),
"and the summary names the axis it saw, so a wheel that arrived is visible")
m3 = wt.MouseWatch(False)
m3.event(0, 40, 5) # an axis this tool does not know
check(any("rel40" in l for l in m3.report()),
"an unnamed axis code is counted and shown, never dropped in silence")
m2.event(0, 272, 1)
check("still held" in "\n".join(m2.report()),
"a button the target still believes is down is called out, in those words")
m2.event(0, 272, 0)
check("still held" not in "\n".join(m2.report()),
"and the warning goes away when it is released")
k = wt.KeyboardWatch(False)
k.event(0, 30, 1)
k.event(0, 30, 0)
check("a" in "".join(k.typed), "the keyboard watcher still reassembles typed text")
kc = wt.KeyboardWatch(False)
for code in (29, 56, 111):
kc.event(0, code, 1)
check(any("Ctrl+Alt+Del" in l for l in kc.report()),
"and still names Ctrl+Alt+Del as one combination, not three keys")
print(f"relayctl {checks} checks, {failures} failures")
sys.exit(1 if failures else 0)