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:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user