Initial import: keyrelay, a WiFi-controlled USB HID keyboard

Browser or terminal controller drives an ESP32-S3 that acts as a Logitech
G815 into a target machine running no software. Disarmed by default; arming
requires the passphrase, the board button, or the serial console, and every
fail-safe path cancels in-flight work.

- src/: firmware with pure, host-testable modules (keystate, proto, text,
  ws_codec, sha1), the WebSocket control server, and the served page
- tests/: host suites for the pure modules, relayctl parity, and page JS
- tools/: relayctl terminal controller, serial console, bootloader helper,
  and an evdev target watcher
- platformio.ini: bridge (USB HID) and console (no-HID) environments
- README.md: single source of truth for behaviour, protocol and safety

LLM-Generated: true
This commit is contained in:
2026-11-08 00:20:33 -05:00
commit 9bc8d5213c
27 changed files with 8243 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
/**
* 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;
uint8_t ledState = 0; // the target's caps/num/scroll LEDs, per its HID reports
bool defaultToken = false;
bool apForced = false;
};
/**
* 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; }
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;
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