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
+5 -4
View File
@@ -4,9 +4,10 @@
# tests/run_tests.sh build and run every suite
# tests/run_tests.sh clean remove the build directory
#
# The modules under test (src/keystate.h, src/proto.h, src/text.h, src/ws_codec.h,
# src/sha1.h) have no Arduino or ESP-IDF dependency by design, so the exact code that
# runs on the board is compiled and exercised here before anything is flashed.
# The modules under test (src/keystate.h, src/proto.h, src/text.h, src/mouse.h,
# src/output.h, src/ws_codec.h, src/sha1.h) have no Arduino or ESP-IDF dependency by
# design, so the exact code that runs on the board is compiled and exercised here before
# anything is flashed.
#
# A C++ compiler is not always installed on a workstation, so the runner picks one:
# $CXX if you set it, it wins
@@ -32,7 +33,7 @@ images=(
"docker.io/library/gcc:14"
)
suites=(test_keystate test_proto test_text test_ws_codec)
suites=(test_keystate test_proto test_text test_mouse test_output test_ws_codec)
build_and_run() {
local cxx="$1"
+157
View File
@@ -0,0 +1,157 @@
// tests for the emulated mouse: tests/test_mouse.cpp
//
// The two things worth proving about a relative mouse are that a frame which changes
// nothing sends nothing, and that a button release is never conditional on movement.
// Everything else about it is arithmetic.
#include <cstddef>
#include <cstring>
#include "mouse.h"
#include "test.h"
using kr::mouse::Button;
using kr::mouse::Frame;
using kr::mouse::MouseReport;
using kr::mouse::MouseState;
namespace {
bool allZero(const MouseReport &r) {
const uint8_t *b = reinterpret_cast<const uint8_t *>(&r);
for (size_t i = 0; i < sizeof(MouseReport); i++) {
if (b[i] != 0) return false;
}
return true;
}
} // namespace
int main() {
CASE("the report is the five bytes the descriptor promises");
{
CHECK_EQ(sizeof(MouseReport), 5);
// The layout is asserted, not assumed: buttons, X, Y, wheel, pan. A struct that
// reordered itself would still compile and would move the target's cursor with the
// wheel byte.
CHECK_EQ(offsetof(MouseReport, buttons), 0);
CHECK_EQ(offsetof(MouseReport, dx), 1);
CHECK_EQ(offsetof(MouseReport, dy), 2);
CHECK_EQ(offsetof(MouseReport, wheel), 3);
CHECK_EQ(offsetof(MouseReport, pan), 4);
}
CASE("button bits are the report's order, not the DOM's");
{
CHECK_EQ(kr::mouse::kBtnLeft, 0x01);
CHECK_EQ(kr::mouse::kBtnRight, 0x02);
CHECK_EQ(kr::mouse::kBtnMiddle, 0x04);
CHECK_EQ(kr::mouse::kBtnBackward, 0x08);
CHECK_EQ(kr::mouse::kBtnForward, 0x10);
CHECK_EQ(kr::mouse::kBtnAll, 0x1F);
}
CASE("movement produces a report, nothing produces nothing");
{
MouseState m;
MouseReport r;
CHECK(m.apply(Frame{0, 30, -4, 0, 0}, r));
CHECK_EQ(r.buttons, 0);
CHECK_EQ(r.dx, 30);
CHECK_EQ(r.dy, -4);
CHECK_EQ(r.wheel, 0);
CHECK_EQ(r.pan, 0);
// An idle frame is the whole endpoint for that frame and says nothing, so it is
// dropped rather than sent -- this is what stops a chatty controller keeping a
// target busy at 125 Hz with a pointer that is not moving.
CHECK(!m.apply(Frame{0, 0, 0, 0, 0}, r));
CHECK(!m.apply(Frame{0, 0, 0, 0, 0}, r));
}
CASE("a button change is a report even with no movement at all");
{
MouseState m;
MouseReport r;
CHECK(m.apply(Frame{kr::mouse::kBtnLeft, 0, 0, 0, 0}, r));
CHECK_EQ(r.buttons, kr::mouse::kBtnLeft);
CHECK(allZero(kr::mouse::kReportClear)); // the report a fail-safe sends
// Repeating the same button byte with no movement is not a new state.
CHECK(!m.apply(Frame{kr::mouse::kBtnLeft, 0, 0, 0, 0}, r));
CHECK(m.apply(Frame{0, 0, 0, 0, 0}, r)); // ...but letting go is
CHECK_EQ(r.buttons, 0);
}
CASE("buttons are restated, not diffed");
{
MouseState m;
MouseReport r;
m.apply(Frame{kr::mouse::kBtnLeft, 0, 0, 0, 0}, r);
m.apply(Frame{kr::mouse::kBtnLeft | kr::mouse::kBtnRight, 0, 0, 0, 0}, r);
CHECK_EQ(r.buttons, kr::mouse::kBtnLeft | kr::mouse::kBtnRight);
// Letting go of one reports the state that remains, which is the whole reason a
// frame lost in the network cannot strand a button: the next frame is the truth.
m.apply(Frame{kr::mouse::kBtnRight, 5, 0, 0, 0}, r);
CHECK_EQ(r.buttons, kr::mouse::kBtnRight);
CHECK_EQ(r.dx, 5);
CHECK_EQ(m.buttons(), kr::mouse::kBtnRight);
CHECK(m.down());
}
CASE("only the five buttons the report has");
{
MouseState m;
MouseReport r;
// The parser refuses a sixth bit, so this is a second line of defence rather than
// the behaviour anyone relies on: whatever arrives cannot set a padding bit.
m.apply(Frame{0xFF, 1, 0, 0, 0}, r);
CHECK_EQ(r.buttons, 0x1F);
CHECK_EQ(m.buttons(), 0x1F);
}
CASE("releaseAll lets go, from any state, and is idempotent");
{
MouseState m;
MouseReport r;
// Nothing was ever down; letting go is still well-defined and leaves the same state.
m.releaseAll();
CHECK_EQ(m.buttons(), 0);
CHECK(!m.down());
m.apply(Frame{kr::mouse::kBtnMiddle, 0, 0, 0, 0}, r);
m.releaseAll();
CHECK_EQ(m.buttons(), 0);
CHECK(!m.down());
m.releaseAll();
CHECK_EQ(m.buttons(), 0);
// And after letting go, the next movement frame restates the empty button byte -- so
// a release that never reached the target is corrected by the next one regardless.
CHECK(m.apply(Frame{0, 4, 0, 0, 0}, r));
CHECK_EQ(r.buttons, 0);
}
CASE("the full range of one signed byte survives");
{
MouseState m;
MouseReport r;
CHECK(m.apply(Frame{0, 127, -127, 127, -127}, r));
CHECK_EQ(r.dx, 127);
CHECK_EQ(r.dy, -127);
CHECK_EQ(r.wheel, 127);
CHECK_EQ(r.pan, -127);
}
CASE("report equality is byte equality");
{
const MouseReport a{1, 2, 3, 4, 5};
const MouseReport b{1, 2, 3, 4, 5};
const MouseReport c{1, 2, 3, 4, 6};
const MouseReport d{0, 2, 3, 4, 5};
CHECK(kr::mouse::reportsEqual(a, b));
CHECK(!kr::mouse::reportsEqual(a, c)); // pan differs: still a different report
CHECK(!kr::mouse::reportsEqual(a, d));
}
return report("mouse");
}
+282
View File
@@ -0,0 +1,282 @@
// tests for the emit side of the bridge: tests/test_output.cpp
//
// This suite exists mainly for one property, and it is the property the project got
// wrong once: releasing everything has to TELL the target. Clearing the tracker and the
// queue is not a release, because the last report the target received still says keys are
// down -- and disarm is exactly the moment the controller stops talking, so no later
// frame is coming to carry it. With a mouse added, the same shape appears a second time:
// a button held at disarm time is a drag nobody is holding.
#include <cstring>
#include "config.h"
#include "keystate.h"
#include "mouse.h"
#include "output.h"
#include "test.h"
using kr::KeyboardReport;
using kr::mouse::Frame;
using kr::mouse::kBtnLeft;
using kr::mouse::kBtnRight;
using kr::mouse::MouseReport;
using kr::OutputStage;
namespace {
bool keysClear(const KeyboardReport &r) {
return r.modifiers == 0 && r.reserved == 0 && r.keys[0] == 0 && r.keys[1] == 0 &&
r.keys[2] == 0 && r.keys[3] == 0 && r.keys[4] == 0 && r.keys[5] == 0;
}
bool mouseClear(const MouseReport &r) {
return r.buttons == 0 && r.dx == 0 && r.dy == 0 && r.wheel == 0 && r.pan == 0;
}
// Drain a queue into a flat list so "what would the target have received" is a fact a
// test can count, not an inference about the firmware's loop.
size_t drainKeyboard(OutputStage &s, KeyboardReport *out, size_t cap) {
size_t n = 0;
while (!s.keyQ.empty() && n < cap) {
out[n++] = s.keyQ.front();
s.keyQ.pop();
}
return n;
}
size_t drainMouse(OutputStage &s, MouseReport *out, size_t cap) {
size_t n = 0;
while (!s.mouseQ.empty() && n < cap) {
out[n++] = s.mouseQ.front();
s.mouseQ.pop();
}
return n;
}
} // namespace
int main() {
CASE("a queue keeps distinct transitions and collapses identical ones");
{
OutputStage s;
s.keys.apply(0, nullptr, 0);
CHECK(s.pushKeys() == OutputStage::Pushed::Nothing); // nothing changed yet
const uint8_t a = 0x04;
s.keys.apply(0, &a, 1);
CHECK(s.pushKeys() == OutputStage::Pushed::Ok);
CHECK(s.pushKeys() == OutputStage::Pushed::Nothing); // restating is a no-op
// A fast press and release is two reports. Collapsing those is how a tap vanishes,
// so only *identical* neighbours may collapse.
s.keys.apply(0, nullptr, 0);
CHECK(s.pushKeys() == OutputStage::Pushed::Ok);
KeyboardReport got[8];
CHECK_EQ(drainKeyboard(s, got, 8), 2);
CHECK_EQ(got[0].keys[0], 0x04);
CHECK(keysClear(got[1]));
}
CASE("release with keys held queues the all-up report <-- the bug this guards");
{
OutputStage s;
const uint8_t k = 0x04;
s.keys.apply(kr::kModLshift, &k, 1);
CHECK(s.pushKeys() == OutputStage::Pushed::Ok);
CHECK(s.releaseAll(true, true));
KeyboardReport got[8];
const size_t n = drainKeyboard(s, got, 8);
CHECK_EQ(n, 1); // exactly one report: the release
CHECK(keysClear(got[0])); // and it says nothing is pressed
CHECK_EQ(s.keys.held(), 0);
}
CASE("release drops presses that had not gone out yet");
{
OutputStage s;
const uint8_t k = 0x04;
s.keys.apply(0, &k, 1);
s.pushKeys(); // queued, never sent
s.releaseAll(true, true);
KeyboardReport got[8];
CHECK_EQ(drainKeyboard(s, got, 8), 1);
CHECK(keysClear(got[0])); // the press is gone; only the release is left
}
CASE("release with buttons held queues the all-up mouse report");
{
OutputStage s;
CHECK(s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0}) == OutputStage::Pushed::Ok);
CHECK(s.releaseAll(true, true));
MouseReport got[8];
CHECK_EQ(drainMouse(s, got, 8), 1);
CHECK(mouseClear(got[0]));
CHECK_EQ(s.mouseState.buttons(), 0);
}
CASE("a mouse the target cannot receive is never queued, even by a fail-safe");
{
OutputStage s;
// The boot-protocol case. A 5-byte report reaching a target that is reading an
// 8-byte boot keyboard report is not "the mouse is unavailable", it is keystrokes
// nobody pressed -- so the release must not sneak one out on the way past.
s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0});
CHECK_EQ(s.mouseQ.count(), 1u);
s.releaseAll(true, false);
CHECK_EQ(s.mouseQ.count(), 0u);
KeyboardReport got[8];
CHECK_EQ(drainKeyboard(s, got, 8), 1); // the keyboard is unaffected ...
CHECK(keysClear(got[0]));
// ... and the button byte is cleared regardless, because what the board believes must
// not outlive the fail-safe just because the target could not be told.
CHECK_EQ(s.mouseState.buttons(), 0);
}
CASE("release covers both devices in one call");
{
OutputStage s;
const uint8_t k = 0x04;
s.keys.apply(0, &k, 1);
s.pushKeys();
s.pushMouse(Frame{kBtnLeft | kBtnRight, 3, 4, 0, 0});
CHECK_EQ(s.keyQ.count(), 1u);
CHECK_EQ(s.mouseQ.count(), 1u);
CHECK(s.releaseAll(true, true));
KeyboardReport kr8[8];
MouseReport mr8[8];
CHECK_EQ(drainKeyboard(s, kr8, 8), 1);
CHECK_EQ(drainMouse(s, mr8, 8), 1);
CHECK(keysClear(kr8[0]));
CHECK(mouseClear(mr8[0]));
}
CASE("release with nothing held still says so, and that is the trade");
{
OutputStage s;
// The reports are unconditional on purpose: a panic path must not hinge on the
// tracker's bookkeeping being right, and "no buttons, no movement" is what a real
// mouse sends every time it presses a button without moving. Two extra reports on a
// 1 ms bus, and no branch in the fail-safe.
CHECK(s.releaseAll(true, true));
KeyboardReport kr8[8];
MouseReport mr8[8];
CHECK_EQ(drainKeyboard(s, kr8, 8), 1);
CHECK_EQ(drainMouse(s, mr8, 8), 1);
CHECK(keysClear(kr8[0]));
CHECK(mouseClear(mr8[0]));
CHECK_EQ(s.keys.held(), 0);
CHECK_EQ(s.mouseState.buttons(), 0);
}
CASE("release with no target attached queues nothing to be counted as dropped");
{
OutputStage s;
const uint8_t k = 0x04;
s.keys.apply(0, &k, 1);
s.pushKeys();
s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0});
// Neither device reachable: the drain counts whatever is left as dropped, so the
// fail-safe must not add release reports nobody can receive. The state is still
// cleared, which is the part that matters for whatever comes next.
CHECK(!s.releaseAll(false, false));
CHECK_EQ(s.keyQ.count(), 0u);
CHECK_EQ(s.mouseQ.count(), 0u);
CHECK_EQ(s.keys.held(), 0);
CHECK_EQ(s.mouseState.buttons(), 0);
}
CASE("release cancels a pasted run -- clearing means stop, not pause");
{
OutputStage s;
const uint8_t text[] = {'h', 'e', 'l', 'l', 'o'};
CHECK(s.typer.feed(text, sizeof(text)).result == kr::text::Result::Ok);
s.typer.start(1000, 12);
CHECK(s.typer.active());
CHECK_EQ(s.typer.pending(), 5u);
s.releaseAll(true, true);
CHECK(!s.typer.active());
CHECK_EQ(s.typer.pending(), 0u);
}
CASE("release ends a chord mid-hold");
{
OutputStage s;
s.chord.active = true;
s.chord.releaseAtMs = 999999;
s.releaseAll(true, true);
CHECK(!s.chord.active);
}
CASE("an idle mouse frame costs nothing and is not queued");
{
OutputStage s;
CHECK(s.pushMouse(Frame{0, 0, 0, 0, 0}) == OutputStage::Pushed::Nothing);
CHECK_EQ(s.mouseQ.count(), 0u);
CHECK(s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0}) == OutputStage::Pushed::Ok);
CHECK(s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0}) == OutputStage::Pushed::Nothing);
CHECK_EQ(s.mouseQ.count(), 1u);
}
CASE("a full queue is refused, and says so");
{
OutputStage s;
// Every report differs, so nothing collapses and the queue really fills.
for (size_t i = 0; i < kr::kMouseReportQueueDepth; i++) {
CHECK(s.pushMouse(Frame{0, (int8_t)(i + 1), 0, 0, 0}) == OutputStage::Pushed::Ok);
}
CHECK_EQ(s.mouseQ.count(), (unsigned)kr::kMouseReportQueueDepth);
// Not "drop the movement, it is only a movement": the frame that does not fit may be
// the one releasing a held button, and that is a stuck-mouse failure.
CHECK(s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0}) == OutputStage::Pushed::Overflow);
const uint8_t lo = 0x04, hi = 0x05;
for (size_t i = 0; i < kr::kReportQueueDepth; i++) {
const uint8_t k = (i & 1) ? lo : hi; // alternating: no two neighbours equal
CHECK(s.pushKeyboardReport(kr::makeReport(0, &k, 1)) == OutputStage::Pushed::Ok);
}
CHECK_EQ(s.keyQ.count(), (unsigned)kr::kReportQueueDepth);
const uint8_t other = 0x06;
CHECK(s.pushKeyboardReport(kr::makeReport(0, &other, 1)) ==
OutputStage::Pushed::Overflow);
// Overflow leaves the queue untouched: the caller's response is releaseEverything(),
// and half-dropping a state transition is the thing that must not happen.
CHECK_EQ(s.keyQ.count(), (unsigned)kr::kReportQueueDepth);
CHECK(s.releaseAll(true, true));
CHECK_EQ(s.keyQ.count(), 1u); // ...and the release still gets out
CHECK_EQ(s.mouseQ.count(), 1u);
}
CASE("queue depths are the ones config.h declares");
{
OutputStage s;
CHECK_EQ(s.keyQ.capacity(), (unsigned)kr::kReportQueueDepth);
CHECK_EQ(s.mouseQ.capacity(), (unsigned)kr::kMouseReportQueueDepth);
CHECK(kr::kMouseReportQueueDepth >= 8); // a flick is tens of reports
CHECK(kr::kMouseReportQueueDepth <= 64); // and it is RAM on an 8 MB board
}
CASE("order is preserved across a movement burst under a held button");
{
OutputStage s;
s.pushMouse(Frame{kBtnLeft, 0, 0, 0, 0});
s.pushMouse(Frame{kBtnLeft, 10, 0, 0, 0});
s.pushMouse(Frame{kBtnLeft, 10, 0, 0, 0}); // identical neighbour: collapses
s.pushMouse(Frame{kBtnLeft, 7, 0, 0, 0});
s.pushMouse(Frame{0, 0, 0, 0, 0});
MouseReport got[8];
CHECK_EQ(drainMouse(s, got, 8), 4); // 5 frames in, one identical pair out
CHECK_EQ(got[0].buttons, kBtnLeft);
CHECK_EQ(got[0].dx, 0);
CHECK_EQ(got[1].dx, 10);
CHECK_EQ(got[2].dx, 7); // the repeated +10 never arrived twice
CHECK_EQ(got[2].buttons, kBtnLeft);
CHECK(mouseClear(got[3])); // and the release is last
}
return report("output");
}
+227 -9
View File
@@ -37,11 +37,33 @@ function makeEl(tagName) {
appendChild(c) { this.children.push(c); return c; },
removeChild(c) { this.children = this.children.filter(x => x !== c); return c; },
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); },
// Element handlers are reachable too: the mouse pad is clicked, and a real click is
// what the browser turns into a Pointer Lock request.
fire(t, ev) {
for (const fn of (this.listeners[t] || [])) fn(ev || { preventDefault() {} });
},
getAttribute() { return null; }, setAttribute() {}, focus() {},
};
return el;
}
// Document- and window-level listeners are recorded rather than dropped. Every
// fail-safe this page has is triggered by something the browser does to the document --
// blur, visibilitychange, pointerlockchange -- so a shim that swallows them cannot
// prove them.
function makeListeners() {
const map = {};
return {
map,
add(t, fn) { (map[t] = map[t] || []).push(fn); },
fire(t, ev) {
for (const fn of (map[t] || [])) fn(ev || { preventDefault() {}, target: null });
},
};
}
const docListeners = makeListeners();
const winListeners = makeListeners();
// The shim hands out an element for any id, which is far less work than a real DOM.
// The weakness in that is obvious, so it is compensated for: every id the script asks
// for is recorded and checked against the ids that are actually in the markup at the
@@ -70,16 +92,26 @@ class FakeWebSocket {
FakeWebSocket.OPEN = 1;
FakeWebSocket.instances = [];
const sandbox = {
document: {
getElementById: byId,
createElement: t => makeEl(t),
querySelectorAll: () => [],
addEventListener() {},
body: makeEl("body"),
hidden: false,
const doc = {
getElementById: byId,
createElement: t => makeEl(t),
querySelectorAll: () => [],
addEventListener: docListeners.add,
body: makeEl("body"),
hidden: false,
// Pointer Lock, as much of it as the page touches. exitPointerLock fires the change
// event because a real browser does, which is what makes the release path testable.
pointerLockElement: null,
exitPointerLock() {
if (!doc.pointerLockElement) return;
doc.pointerLockElement = null;
docListeners.fire("pointerlockchange", {});
},
window: { addEventListener() {} },
};
const sandbox = {
document: doc,
window: { addEventListener: winListeners.add },
location: { protocol: "http:", host: "keyrelay.local" },
sessionStorage: { getItem: () => null, setItem() {} },
WebSocket: FakeWebSocket,
@@ -95,6 +127,11 @@ const expose = `
log, logEl, untypeableChars, hexify, describePress, inPageField, pasteText,
sendPaste, refreshSendBox, setArmed, abortTyping, onFrame, connect,
TEXT_FRAME_MAX, TEXT_JOB_MAX, TEXT_PACE_MS,
takeMouse, dropMouse, flushMouse, mouseFrame, paintMouse, refreshMouseUi,
MOUSE_DELTA_MAX, MOUSE_FRAME_MS, MOUSE_MAX_FRAMES, MOUSE_WHEEL_PX,
get mCaptured() { return mCaptured; }, set mCaptured(v) { mCaptured = v; },
get mButtons() { return mButtons; },
get pendX() { return pendX; }, get pendY() { return pendY; },
get ws() { return ws; },
get armed() { return armed; }, set armed(v) { armed = v; },
get authed() { return authed; }, set authed(v) { authed = v; },
@@ -131,7 +168,29 @@ const checkEq = (a, b, label) => {
}
};
const frames = () => sentFrames.filter(f => f.startsWith("P="));
const mframes = () => sentFrames.filter(f => f.startsWith("M="));
const textOf = f => Buffer.from(f.slice(f.indexOf(":") + 1), "hex").toString();
// The pad the page asks for Pointer Lock, wired the way a browser wires it: the request
// sets pointerLockElement and fires pointerlockchange.
function givePointerLock() {
const pad = byId("mPad");
pad.requestPointerLock = () => {
doc.pointerLockElement = pad;
docListeners.fire("pointerlockchange", {});
return Promise.resolve();
};
return pad;
}
function takePointerLockAway() {
doc.pointerLockElement = null;
docListeners.fire("pointerlockchange", {});
}
const move = (dx, dy) => docListeners.fire("mousemove", { movementX: dx, movementY: dy });
const btn = (b, down) => docListeners.fire(down ? "mousedown" : "mouseup",
{ button: b, preventDefault() {} });
const scroll = (dy, dx, mode) => docListeners.fire("wheel",
{ deltaY: dy, deltaX: dx || 0, deltaMode: mode || 0, preventDefault() {} });
const wait = ms => new Promise(r => setTimeout(r, ms));
console.log("the log stays readable");
@@ -264,6 +323,165 @@ checkEq(!!x.inPageField({ target: null }), false, "a missing target does not thr
await p;
checkEq(frames().length, 1, "a disarm mid-run stops the run");
console.log("the mouse");
givePointerLock();
x.setArmed(true, "harness: armed");
x.authed = true;
// A capture with nothing behind it must not produce a single frame.
x.setArmed(false, "harness: disarmed");
sentFrames.length = 0;
x.takeMouse();
await wait(2);
checkEq(mframes().length, 0, "a disarmed bridge is not offered the mouse");
checkEq(x.mCaptured, false, "and the pointer is not taken");
x.setArmed(true, "harness: armed");
sentFrames.length = 0;
x.takeMouse();
await wait(2);
checkEq(x.mCaptured, true, "capture takes the pointer when the bridge is armed");
checkEq(mframes().length, 0, "taking the pointer sends no movement nobody made");
move(30, -4);
sentFrames.length = 0;
x.flushMouse();
checkEq(mframes().length, 1, "one flush of one small movement is one frame");
checkEq(mframes()[0], "M=00:30:-4:0:0", "the button byte first, then this frame's movement");
sentFrames.length = 0;
x.flushMouse();
checkEq(mframes().length, 0, "a frame that says nothing is not sent at all");
// A fast flick: the report carries one signed byte per axis, so the tab splits it.
move(300, -260);
sentFrames.length = 0;
x.flushMouse();
const big = mframes();
const sum = i => big.reduce((a, f) => a + Number(f.split(":")[i]), 0);
checkEq(sum(1), 300, "300 px of X arrives as 300 px, split across reports");
checkEq(sum(2), -260, "and so does the Y");
check(big.every(f => [1, 2, 3, 4].every(i => Math.abs(Number(f.split(":")[i]))
<= x.MOUSE_DELTA_MAX)),
"and no single report asks for more than one signed byte");
// Order is the whole difference between a click and a drag.
move(12, 0);
sentFrames.length = 0;
btn(0, true);
const order = sentFrames.filter(f => f.startsWith("M="));
checkEq(order.length, 2, "a press with movement behind it is two frames");
checkEq(order[0], "M=00:12:0:0:0", "the movement first, under the old button state");
checkEq(order[1], "M=01:0:0:0:0", "then the button, so the target sees press then move");
checkEq(x.mButtons, 0x01, "left is held");
sentFrames.length = 0;
btn(0, false);
checkEq(mframes()[0], "M=00:0:0:0:0", "release restates the whole button byte as nothing");
checkEq(x.mButtons, 0, "and the tab agrees");
// Two buttons at once, and letting go of one: the byte that goes out is the state,
// not the change. (DOM button 0 = left -> 0x01, 2 = right -> 0x02, 1 = middle -> 0x04.)
btn(0, true);
btn(2, true);
sentFrames.length = 0;
btn(0, false);
checkEq(mframes()[0], "M=02:0:0:0:0", "right stays down when the left button goes up");
sentFrames.length = 0;
scroll(100, 0); // one detent's worth of pixels
scroll(100, 0);
scroll(25, 0); // not yet a detent: carried, not dropped
x.flushMouse();
checkEq(mframes()[0], "M=02:0:0:2:0", "wheel travels in detents and the remainder waits");
sentFrames.length = 0;
scroll(3, 0, 1); // deltaMode 1: lines, one detent each
x.flushMouse();
checkEq(mframes()[0], "M=02:0:0:3:0", "a line is a detent, whatever the pixel density");
// The fail-safes. Each one has to let go of the buttons, whatever else it does.
btn(2, false);
btn(0, true);
checkEq(x.mButtons, 0x01, "holding left again for the release tests");
sentFrames.length = 0;
takePointerLockAway();
checkEq(mframes()[0], "M=00:0:0:0:0",
"losing the pointer lock (Esc, focus, the browser) releases every button");
checkEq(x.mCaptured, false, "and the tab stops capturing");
x.takeMouse();
await wait(2);
btn(1, true);
sentFrames.length = 0;
doc.pointerLockElement = null; // the browser already dropped it; blur follows
winListeners.fire("blur", {});
check(mframes().includes("M=00:0:0:0:0"),
"a blur releases the buttons too");
checkEq(x.mCaptured, false, "and ends the capture");
// The stay-armed-on-blur option is an exception for the keyboard and must not become
// one for the cursor: a pointer nobody is looking at is not a pointer being aimed.
byId("keepArmed").checked = true;
x.setArmed(true, "harness: armed");
x.takeMouse();
await wait(2);
btn(0, true);
sentFrames.length = 0;
winListeners.fire("blur", {});
check(mframes().includes("M=00:0:0:0:0"),
"stay-armed-on-blur keeps the keyboard armed and still releases the mouse");
checkEq(x.mCaptured, false, "the capture does not survive it either");
byId("keepArmed").checked = false;
x.setArmed(true, "harness: armed");
x.takeMouse();
await wait(2);
btn(0, true);
sentFrames.length = 0;
x.setArmed(false, "harness: disarmed with a button held");
checkEq(x.mCaptured, false, "disarm ends the capture");
checkEq(x.mButtons, 0, "and the tab's own button state");
// The board releases the buttons itself when it disarms (src/output.h), so the tab
// sends an M= frame to a bridge that has already answered: silence here is correct.
checkEq(mframes().length, 0,
"disarm does not beg a bridge that has already let go");
// A board that cannot report a mouse: refused up front, and a running capture ended.
x.setArmed(true, "harness: armed");
sentFrames.length = 0;
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":0}');
x.takeMouse();
await wait(2);
checkEq(x.mCaptured, false, "a board with no mouse interface does not take the pointer");
checkEq(mframes().length, 0, "and gets no frames");
checkEq(byId("mCapture").disabled, true, "the button says so by being disabled");
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":1}');
checkEq(byId("mCapture").disabled, false,
"the button comes back when the board says it can report a mouse");
x.takeMouse();
await wait(2);
checkEq(x.mCaptured, true, "and it captures again once the board can report one");
sentFrames.length = 0;
x.onFrame('S{"armed":1,"usb":1,"typing":0,"mouse":0}');
checkEq(x.mCaptured, false,
"a capture that the board can no longer honour is ended, not left hanging");
// Every frame the tab can emit has to be one the firmware parses: five fields, a hex
// button byte, signed decimal movements, and short enough for one WebSocket message.
const shape = /^M=[0-9a-f]{2}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}:-?\d{1,3}$/;
let malformed = 0, tooLong = 0;
for (const f of mframes()) {
if (!shape.test(f)) malformed++;
if (f.length > 256) tooLong++;
}
checkEq(malformed, 0, "every mouse frame this tab emitted parses");
checkEq(tooLong, 0, "and every one fits in one WebSocket message");
x.mCaptured = false;
x.setArmed(false, "harness: done");
console.log("element ids");
const markup = fs.readFileSync(headerPath, "utf8");
const present = new Set();
+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");
}
+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)