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
+31
View File
@@ -45,11 +45,33 @@ struct StatusSnapshot {
uint32_t typedChars = 0; // characters sent by pasted text (a count, never the text)
uint16_t typing = 0; // characters still queued in the paste in progress
bool rollover = false;
// The mouse: whether it can report at all, which buttons the board believes are
// down, and how many reports have gone out. `mouse` is false when the interface is
// switched off on the board or when the target has forced boot protocol -- in both
// cases the keyboard is untouched and M= frames are refused, not ignored.
bool mouse = false;
uint8_t mouseButtons = 0;
uint32_t mouseFrames = 0;
uint8_t ledState = 0; // the target's caps/num/scroll LEDs, per its HID reports
bool defaultToken = false;
bool apForced = false;
};
// Error codes, named. The wire form is `E<code>:<text>`; see proto.h for the list.
enum ErrorCode : uint8_t {
kErrNotAuthed = 1,
kErrLockedOut = 2,
kErrBusy = 3,
kErrNotArmed = 4,
kErrNoTarget = 5,
kErrBadMessage = 6,
kErrQueueFull = 7,
kErrTextFull = 8,
kErrUntypeable = 9,
kErrMouseDisabled = 10,
kErrMouseBootProtocol = 11,
};
/**
* Renders the status frame. Split out of the class so it is callable from the serial
* console too, and so the field names stay in one place.
@@ -75,6 +97,14 @@ class ControlServer {
void sendError(uint8_t code, const char *text);
bool linked() const { return _phase == Phase::WebSocket && _authed; }
/**
* True from the first successful authentication until reboot. The silence timer needs
* this as well as lastInboundMs(): an armed board with no controller at all (armed by
* its own button, or by the serial console) has nothing to hear from, and must not be
* disarmed for it -- but a board whose controller came and went must never stay armed
* on the strength of a link that no longer exists.
*/
bool controllerSeen() const { return _controllerSeen; }
uint32_t lastInboundMs() const { return _lastInboundMs; }
uint32_t rejected() const { return _rejected; }
uint32_t authFails() const { return _authFails; }
@@ -111,6 +141,7 @@ class ControlServer {
ws::Reader _reader{_wsBuf, sizeof(_wsBuf)};
bool _authed = false;
bool _controllerSeen = false;
uint32_t _connectedAtMs = 0;
uint32_t _lastInboundMs = 0;
uint32_t _lastStatusMs = 0;