Files
keyrelay/src/control_server.h
T
ducoterra ece0313d02 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
2026-11-08 23:27:42 -05:00

161 lines
5.9 KiB
C++

/**
* keyrelay -- the control server: HTTP for the page, WebSocket for the keystrokes.
*
* Shape of it:
* - one controller at a time. A second WebSocket upgrade is refused outright rather
* than queued or swapped silently, because two controllers typing into one machine
* is exactly the ambiguity a keyboard must not have. The page says so (E3).
* Read-only HTTP is *not* refused: what is exclusive is the keyboard, not the board.
* Refusing everything meant pressing reload on your own tab failed, and a live
* controller could not be inspected by anything else (serveViewer).
* - non-blocking throughout: tick() runs once per loop() and consumes only what is
* already buffered. The keystroke path must not wait on the network stack.
* - nothing is trusted before the passphrase. The socket opens, must present T<token>
* inside kAuthTimeoutMs, and is closed otherwise; repeated failures lock the
* evaluation out for kTokenLockMs.
*
* Bytes in are decoded by kr::ws::Reader (host-tested); commands by kr::proto::parse
* (host-tested). What is left here is routing, framing and lifecycle -- the part that
* genuinely needs a socket to be worth testing.
*/
#pragma once
#include <WiFi.h>
#include "config.h"
#include "proto.h"
#include "ws_codec.h"
namespace kr {
// What the board knows about itself, as shown to the controller. The JSON field
// names in StatusJson below are the contract the page in web_page.h reads.
struct StatusSnapshot {
const char *version = "0";
uint32_t uptimeS = 0;
bool usbHost = false; // TinyUSB is attached to a target
bool armed = false;
bool netUp = false;
const char *net = "down"; // "sta" | "ap" | "down"
const char *ssid = "";
const char *ip = "";
uint32_t keysSent = 0;
uint32_t chordsSent = 0;
uint32_t dropped = 0;
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.
*/
size_t statusJson(const StatusSnapshot &s, char *out, size_t cap);
class ControlServer {
public:
// ctx-first function pointers rather than std::function: no allocation, and the
// wiring in setup() reads as a list of the callbacks the server may invoke.
using OnMessage = void (*)(void *ctx, const proto::Message &msg);
using OnLinkChange = void (*)(void *ctx, bool linked);
using FillStatus = void (*)(void *ctx, StatusSnapshot &out);
using GetToken = const char *(*)(void *ctx);
void begin(OnMessage onMessage, OnLinkChange onLinkChange, FillStatus fillStatus,
GetToken getToken, void *ctx);
void tick(uint32_t nowMs);
// Push a status frame now. Called on the 1 Hz heartbeat and immediately after an
// arm/disarm, so the page's one source of truth is the board, not its own guess.
void sendStatus();
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; }
private:
enum class Phase : uint8_t { None, Http, WebSocket };
void acceptNew(uint32_t nowMs);
void serveHttp(uint32_t nowMs);
void serveReadonly(WiFiClient &c, const ws::Request &req);
void serveViewer(uint32_t nowMs);
void stopViewer();
void serveWebSocket(uint32_t nowMs);
void closeLink(const char *why);
bool handleWsFrame(uint32_t nowMs);
void sendPing();
void writeTextFrame(const char *text, size_t len);
size_t readSome(uint8_t *dst, size_t cap);
WiFiServer _server;
WiFiClient _client;
Phase _phase = Phase::None;
char _http[kHttpHeaderCap];
size_t _httpLen = 0;
// A second socket, read-only, for when the controller slot is taken.
WiFiClient _viewer;
char _viewerHttp[kHttpHeaderCap];
size_t _viewerLen = 0;
uint32_t _viewerAtMs = 0;
uint8_t _wsBuf[kWsMessageCap];
ws::Reader _reader{_wsBuf, sizeof(_wsBuf)};
bool _authed = false;
bool _controllerSeen = false;
uint32_t _connectedAtMs = 0;
uint32_t _lastInboundMs = 0;
uint32_t _lastStatusMs = 0;
uint32_t _lastPingMs = 0;
uint32_t _lockUntilMs = 0;
uint32_t _authFails = 0;
uint32_t _rejected = 0;
OnMessage _onMessage = nullptr;
OnLinkChange _onLinkChange = nullptr;
FillStatus _fillStatus = nullptr;
GetToken _getToken = nullptr;
void *_ctx = nullptr;
};
} // namespace kr