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
+172
View File
@@ -343,6 +343,135 @@ int main() {
CHECK(html.find("data-chord=\"05:04\"") == std::string::npos);
}
CASE("mouse message: the button byte restated, plus one report's movement");
{
Message m;
CHECK(parse("M=00:0:0:0:0", m) == Cmd::Mouse);
CHECK_EQ(m.buttons, 0);
CHECK_EQ(m.dx, 0);
CHECK(parse("M=01:30:-4:0:0", m) == Cmd::Mouse);
CHECK_EQ(m.buttons, 1);
CHECK_EQ(m.dx, 30);
CHECK_EQ(m.dy, -4);
// Every field, at both ends of the range a signed byte carries.
CHECK(parse("M=1f:127:-127:127:-127", m) == Cmd::Mouse);
CHECK_EQ(m.buttons, 0x1f);
CHECK_EQ(m.dx, 127);
CHECK_EQ(m.dy, -127);
CHECK_EQ(m.wheel, 127);
CHECK_EQ(m.pan, -127);
CHECK(parse("M=15:0:0:0:0", m) == Cmd::Mouse);
CHECK_EQ(m.buttons, 0x15);
}
CASE("a mouse frame that is not exactly one report is refused");
{
Message m;
// Rejecting means nothing moves, which for a movement is harmless and for a button
// byte is the only safe answer. Guessing -- clamping, or splitting a big delta into
// several reports -- would make the board responsible for a number the controller
// got wrong.
CHECK(parse("M") == Cmd::Bad);
CHECK(parse("M=") == Cmd::Bad);
CHECK(parse("M=00") == Cmd::Bad);
CHECK(parse("M=00:") == Cmd::Bad);
CHECK(parse("M=00:0") == Cmd::Bad);
CHECK(parse("M=00:0:0:0") == Cmd::Bad); // four fields is not five
CHECK(parse("M=00:0:0:0:0:") == Cmd::Bad); // trailing delimiter
CHECK(parse("M=00:0:0:0:0:0") == Cmd::Bad); // ...nor a sixth one
CHECK(parse("M=0:0:0:0:0") == Cmd::Bad); // the button byte is two hex digits
CHECK(parse("M=20:0:0:0:0") == Cmd::Bad); // a sixth button has nowhere to go
CHECK(parse("M=ff:0:0:0:0") == Cmd::Bad);
CHECK(parse("M=00:128:0:0:0") == Cmd::Bad); // more than one report's worth
CHECK(parse("M=00:-128:0:0:0") == Cmd::Bad);
CHECK(parse("M=00:0:128:0:0") == Cmd::Bad);
CHECK(parse("M=00:+5:0:0:0") == Cmd::Bad); // no plus sign, whatever JS emits
CHECK(parse("M=00:5 :0:0:0") == Cmd::Bad); // no spaces
CHECK(parse("M=00:5x:0:0:0") == Cmd::Bad);
CHECK(parse("M=00::0:0:0") == Cmd::Bad); // an empty field is not a zero
CHECK(parse("m=00:0:0:0:0") == Cmd::Bad); // commands are uppercase
CHECK(parse("M=00:0:0:0:0 ") == Cmd::Bad); // nothing trails a command
CHECK(parse("M=00:0:0:0:0\n") == Cmd::Mouse); // ...except a hand-typed newline
// -0 is a hand-typed oddity, not an ambiguity.
CHECK(parse("M=00:-0:0:0:0", m) == Cmd::Mouse);
CHECK_EQ(m.dx, 0);
}
CASE("the board refuses to render a mouse frame it would not parse");
{
char buf[64];
CHECK_EQ(kr::proto::formatMouse(buf, sizeof(buf), 0, 0, 0, 0, 0), 12u);
CHECK_STR(buf, "M=00:0:0:0:0");
CHECK(kr::proto::formatMouse(buf, sizeof(buf), 0x01, 30, -4, 0, 0) > 0);
CHECK_STR(buf, "M=01:30:-4:0:0");
CHECK(kr::proto::formatMouse(buf, sizeof(buf), 0x1f, -127, 127, -127, 127) > 0);
CHECK_STR(buf, "M=1f:-127:127:-127:127");
CHECK_EQ(kr::proto::formatMouse(buf, sizeof(buf), 0x20, 0, 0, 0, 0), 0u);
CHECK_STR(buf, ""); // nothing half-written
CHECK_EQ(kr::proto::formatMouse(buf, sizeof(buf), 0, 128, 0, 0, 0), 0u);
CHECK_EQ(kr::proto::formatMouse(buf, sizeof(buf), 0, 0, 0, 0, -128), 0u);
// A buffer that is too small yields 0 and an empty string, never a truncated frame:
// a frame cut in half is a command the board did not receive, on a target that is
// waiting for the rest of it.
char tiny[12];
CHECK_EQ(kr::proto::formatMouse(tiny, sizeof(tiny), 0, 127, 127, 127, 127), 0u);
CHECK_STR(tiny, "");
}
CASE("a rendered mouse frame parses back to the same report");
{
const uint8_t bs[] = {0x00, 0x01, 0x02, 0x04, 0x08, 0x1f};
const int8_t vs[] = {0, 1, -1, 127, -127, 42, -42};
for (size_t b = 0; b < sizeof(bs); b++) {
for (size_t i = 0; i < sizeof(vs); i++) {
for (size_t j = 0; j < sizeof(vs); j++) {
char buf[64];
const size_t n = kr::proto::formatMouse(buf, sizeof(buf), bs[b], vs[i], vs[j],
(int8_t)(-(int)vs[i]), vs[j]);
if (n == 0) { CHECK(false); continue; }
Message m;
CHECK(kr::proto::parse(buf, n, m) == Cmd::Mouse);
CHECK_EQ(m.buttons, bs[b]);
CHECK_EQ(m.dx, vs[i]);
CHECK_EQ(m.dy, vs[j]);
CHECK_EQ(m.wheel, -vs[i]);
CHECK_EQ(m.pan, vs[j]);
CHECK(n <= kr::kWsMessageCap);
}
}
}
}
CASE("error codes above 9 are not truncated to their last digit");
{
char buf[96];
// The single-digit codes must render exactly as they always did, or every client
// that branches on them breaks.
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 1, "x"), 4u);
CHECK_STR(buf, "E1:x");
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 9, "no key"), 9u);
CHECK_STR(buf, "E9:no key");
// 10 and 11 are the mouse's, and `code % 10` used to turn them into E0 -- a code no
// client would recognise, on the one path where a client needs to know.
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 10, "off"), 7u);
CHECK_STR(buf, "E10:off");
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 11, "boot"), 8u);
CHECK_STR(buf, "E11:boot");
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 0, "z"), 4u);
CHECK_STR(buf, "E0:z");
// Never a truncated frame: a client cannot parse half of one, and cannot tell an
// over-long error from a real one.
CHECK_EQ(kr::proto::formatError(buf, 7, 10, "too long"), 0u);
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 10, ""), 4u);
CHECK_STR(buf, "E10:");
}
CASE("the page's text constants are the board's text constants");
{
// The page splits a paste by kTextFrameMax and refuses more than kTextJobCap,
@@ -363,5 +492,48 @@ int main() {
CHECK(html.find("\"P=\" + TEXT_PACE_MS") != std::string::npos);
}
CASE("the page's mouse constants are the board's mouse constants");
{
// Same hand-copied-number hazard as the text ones: if the page thinks one report
// carries 255 of something, the board refuses every frame at 128.
std::string html = readFile("src/web_page.h");
if (html.empty()) html = readFile("../src/web_page.h");
CHECK(!html.empty());
char needle[80];
snprintf(needle, sizeof(needle), "const MOUSE_DELTA_MAX = %d;", (int)kr::kMouseDeltaMax);
CHECK(html.find(needle) != std::string::npos);
snprintf(needle, sizeof(needle), "const MOUSE_BUTTON_MASK = 0x%x;",
(unsigned)kr::kMouseButtonMask);
CHECK(html.find(needle) != std::string::npos);
// The page must build the frame the firmware parses, with all five fields -- a
// missing one is a refused frame, and the page would look like it was moving the
// mouse while nothing arrived.
CHECK(html.find("function mouseFrame(buttons, dx, dy, wheel = 0, pan = 0)") !=
std::string::npos);
CHECK(html.find("\":\" + dx + \":\" + dy + \":\" + wheel + \":\" + pan") !=
std::string::npos);
// DOM MouseEvent.button -> report bit. The DOM numbers are not in HID order: 1 is
// the middle button and 2 is the right one. A page that got these the wrong way round
// would send a right-click as a middle-click, parse cleanly, and be wrong -- the
// Alt+Tab failure all over again, so the mapping is pinned here and in relayctl.
CHECK(html.find("0: 0x01, 2: 0x02, 1: 0x04, 3: 0x08, 4: 0x10") != std::string::npos);
// The mouse has to be released by the same fail-safes the keys are.
CHECK(html.find("dropMouse(\"disarmed\")") != std::string::npos);
CHECK(html.find("dropMouse(\"tab lost focus\")") != std::string::npos);
CHECK(html.find("dropEverything(\"tab hidden\")") != std::string::npos);
// ...and the board's own release funnel must be the one that releases the buttons
// when the page could not.
std::string out = readFile("src/output.h");
if (out.empty()) out = readFile("../src/output.h");
CHECK(!out.empty());
CHECK(out.find("mouseState.releaseAll()") != std::string::npos);
CHECK(out.find("mouse::kReportClear") != std::string::npos);
CHECK(out.find("typer.cancel()") != std::string::npos);
}
return report("proto");
}