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
161 lines
5.9 KiB
C++
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
|