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
+367
View File
@@ -0,0 +1,367 @@
// tests for the control protocol: tests/test_proto.cpp
#include <cstdio>
#include <cstring>
#include <string>
#include "keystate.h"
#include "proto.h"
#include "test.h"
using kr::proto::Cmd;
using kr::proto::Message;
namespace {
Cmd parse(const char *s) {
static Message m;
kr::proto::initMessage(m);
const Cmd c = kr::proto::parse(s, strlen(s), m);
return c;
}
Cmd parse(const char *s, Message &m) { return kr::proto::parse(s, strlen(s), m); }
std::string readFile(const char *path) {
std::string out;
FILE *f = fopen(path, "rb");
if (!f) return out;
char buf[4096];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n);
fclose(f);
return out;
}
} // namespace
int main() {
CASE("single-character commands");
{
CHECK(parse("H") == Cmd::Hello);
CHECK(parse("K") == Cmd::KeepAlive);
CHECK(parse("R") == Cmd::ReleaseAll);
CHECK(parse("C") == Cmd::SecureAttention);
CHECK(parse("A1") == Cmd::Arm);
CHECK(parse("A0") == Cmd::Disarm);
// hand-typed clients end lines with \n: tolerated
CHECK(parse("A1\n") == Cmd::Arm);
CHECK(parse("R\r\n") == Cmd::ReleaseAll);
}
CASE("garbage is rejected, never half-accepted");
{
CHECK(parse("") == Cmd::Bad);
CHECK(parse("A") == Cmd::Bad); // arm/disarm needs its digit
CHECK(parse("A2") == Cmd::Bad);
CHECK(parse("A11") == Cmd::Bad);
CHECK(parse("HH") == Cmd::Bad);
CHECK(parse("Z") == Cmd::Bad);
CHECK(parse("hello") == Cmd::Bad);
CHECK(parse("S") == Cmd::Bad);
CHECK(parse("S=") == Cmd::Bad);
}
CASE("state message: modifiers and usages");
{
Message m;
CHECK(parse("S=00:", m) == Cmd::State);
CHECK_EQ(m.nUsages, 0); // "nothing pressed" is a legal state
CHECK_EQ(m.mods, 0);
CHECK(parse("S=04:28", m) == Cmd::State); // LeftAlt + Enter
CHECK_EQ(m.mods, 0x04);
CHECK_EQ(m.nUsages, 1);
CHECK_EQ(m.usages[0], 0x28);
CHECK(parse("S=0a:04,05,06", m) == Cmd::State);
CHECK_EQ(m.mods, 0x0A);
CHECK_EQ(m.nUsages, 3);
CHECK_EQ(m.usages[2], 0x06);
CHECK(parse("S=0A:AB,cd", m) == Cmd::State); // hex case is free
CHECK_EQ(m.mods, 0x0A);
CHECK_EQ(m.usages[0], 0xAB);
CHECK_EQ(m.usages[1], 0xCD);
}
CASE("state message: strict field syntax");
{
CHECK(parse("S=00:04,") == Cmd::Bad); // trailing comma
CHECK(parse("S=00:4") == Cmd::Bad); // usage must be two hex digits
CHECK(parse("S=00:04gg") == Cmd::Bad); // not hex
CHECK(parse("S=0:0400") == Cmd::Bad); // modifier byte must be two digits
CHECK(parse("S=00:00") == Cmd::Bad); // 0x00 is not a key
CHECK(parse("S=00 :04") == Cmd::Bad); // no spaces anywhere
CHECK(parse("S=00") == Cmd::Bad); // the colon is not optional
CHECK(parse("S =00:") == Cmd::Bad);
}
CASE("state message: bounds");
{
// exactly the cap is fine, one over is not
char big[128];
for (int n = 1; n <= static_cast<int>(kr::kMaxKeysPerMessage) + 2; n++) {
std::strcpy(big, "S=00:");
size_t o = std::strlen(big);
for (int i = 0; i < n; i++) {
big[o++] = '0';
big[o++] = static_cast<char>('4' + (i % 6));
big[o++] = ',';
}
big[o - 1] = '\0';
const bool withinCap = n <= static_cast<int>(kr::kMaxKeysPerMessage);
CHECK(parse(big) == (withinCap ? Cmd::State : Cmd::Bad));
}
}
CASE("chord message must press something");
{
Message m;
CHECK(parse("Y=05:4c", m) == Cmd::Chord); // Ctrl+Alt+Del as a chord
CHECK_EQ(m.mods, 0x05);
CHECK_EQ(m.usages[0], 0x4C);
// A lone Super/Win press is a chord of nothing but a modifier ...
CHECK(parse("Y=08:", m) == Cmd::Chord);
CHECK_EQ(m.mods, 0x08);
CHECK_EQ(m.nUsages, 0);
// ... but a chord that presses neither a modifier nor a key is not a chord.
CHECK(parse("Y=00:") == Cmd::Bad);
CHECK(parse("Y=05") == Cmd::Bad); // the colon is not optional
}
CASE("auth message: token charset and bounds");
{
Message m;
CHECK(parse("Thunter2", m) == Cmd::Auth);
CHECK_STR(m.text, "hunter2");
CHECK(parse("Tabc._-:9", m) == Cmd::Auth);
CHECK_STR(m.text, "abc._-:9");
CHECK(parse("T") == Cmd::Bad); // empty token
CHECK(parse("Tbad token") == Cmd::Bad); // spaces and control chars are out
CHECK(parse("Tbad\tx") == Cmd::Bad);
char over[80];
std::memset(over, 'a', sizeof(over));
over[0] = 'T';
over[kr::kTokenMax + 2] = '\0'; // "T" + 33 chars = one too long
CHECK_EQ(std::strlen(over), kr::kTokenMax + 2u); // "T" + 33 token chars
CHECK(parse(over) == Cmd::Bad);
char fits[80];
std::memset(fits, 'a', sizeof(fits));
fits[0] = 'T';
fits[kr::kTokenMax + 1] = '\0'; // token exactly at the limit
CHECK(parse(fits) == Cmd::Auth);
}
CASE("state renders back to the same wire format");
{
char buf[64];
const uint8_t keys[] = {0x04, 0x28};
CHECK_EQ(kr::proto::formatState(buf, sizeof(buf), 0x0A, keys, 2), 10u);
CHECK_STR(buf, "S=0a:04,28");
// "nothing pressed" needs the colon, and needs a buffer that fits the NUL
CHECK_EQ(kr::proto::formatState(buf, 5, 0, nullptr, 0), 0u);
CHECK_EQ(kr::proto::formatState(buf, 6, 0, nullptr, 0), 5u);
CHECK_STR(buf, "S=00:");
CHECK(kr::proto::formatState(buf, sizeof(buf), 0, nullptr, 0) > 0);
CHECK_STR(buf, "S=00:");
CHECK(kr::proto::formatChord(buf, sizeof(buf), 0x05, keys, 1) > 0);
CHECK_STR(buf, "Y=05:04");
// a too-small buffer reports failure instead of writing past the end
char tiny[6] = {0};
CHECK_EQ(kr::proto::formatState(tiny, sizeof(tiny), 0x0A, keys, 2), 0u);
}
CASE("round trip: render then parse yields the same state");
{
char buf[64];
const uint8_t keys[] = {0x04, 0x05, 0x28, 0x4C};
kr::proto::formatState(buf, sizeof(buf), 0x5A, keys, 4);
Message m;
CHECK(parse(buf, m) == Cmd::State);
CHECK_EQ(m.mods, 0x5A);
CHECK_EQ(m.nUsages, 4);
for (int i = 0; i < 4; i++) CHECK_EQ(m.usages[i], keys[i]);
}
CASE("error frames carry their code");
{
char buf[32];
CHECK(kr::proto::formatError(buf, sizeof(buf), 4, "arm required") > 0);
CHECK_STR(buf, "E4:arm required");
CHECK_EQ(kr::proto::formatError(buf, 4, 4, "too long for this buffer"), 0u);
}
CASE("text message: pace, hex, and the bounds that keep one frame one command");
{
Message m;
CHECK(parse("P=0:6869", m) == Cmd::Text);
CHECK_EQ(m.periodMs, 0); // 0 = the board's own default pace
CHECK_EQ(m.nPayload, 2);
CHECK_EQ(m.payload[0], 'h');
CHECK_EQ(m.payload[1], 'i');
CHECK(parse("P=250:68", m) == Cmd::Text);
CHECK_EQ(m.periodMs, 250);
CHECK(parse("P=1:68", m) == Cmd::Text);
CHECK_EQ(m.periodMs, 1);
// Strict, like every other command: the digits, the colon and a whole number
// of bytes are all required, and a rejected frame types nothing at all.
CHECK(parse("P") == Cmd::Bad);
CHECK(parse("P=") == Cmd::Bad);
CHECK(parse("P=:68") == Cmd::Bad); // the pace is not optional
CHECK(parse("P=12") == Cmd::Bad); // no colon, no text
CHECK(parse("P=12:") == Cmd::Bad); // empty text is not a paste
CHECK(parse("P=12:6") == Cmd::Bad); // half a byte
CHECK(parse("P=12:686") == Cmd::Bad);
CHECK(parse("P=12:zz") == Cmd::Bad);
CHECK(parse("P=12:68 69") == Cmd::Bad); // no spaces
CHECK(parse("P=251:68") == Cmd::Bad); // slower than anything sane
CHECK(parse("P=99999:68") == Cmd::Bad); // and no overflow on the digits
CHECK(parse("P=12:6869extra") == Cmd::Bad);
CHECK(parse("PX:68") == Cmd::Bad);
// One frame is one command: kTextFrameMax characters must fit inside one 256-byte
// frame, and one more must be refused rather than buffered or truncated.
std::string body(2 * kr::kTextFrameMax, '0');
for (size_t i = 0; i < body.size(); i++) body[i] = (i & 1) ? '1' : '6'; // all 'a'
const std::string big = "P=12:" + body;
const std::string more = big + "61";
CHECK_EQ(big.size(), 5u + 2u * kr::kTextFrameMax);
CHECK(big.size() <= kr::kWsMessageCap);
CHECK(kr::proto::parse(big.c_str(), big.size(), m) == Cmd::Text);
CHECK_EQ(m.nPayload, kr::kTextFrameMax);
CHECK_EQ(m.payload[0], 'a');
CHECK(more.size() <= kr::kWsMessageCap); // refused on count, not on length
CHECK(kr::proto::parse(more.c_str(), more.size(), m) == Cmd::Bad);
// A trailing newline is tolerated, as it is for every other command.
CHECK(parse("P=0:6869\n") == Cmd::Text);
}
CASE("constant-time token comparison");
{
CHECK(kr::proto::constTimeEq("hunter2", 7, "hunter2", 7));
CHECK(!kr::proto::constTimeEq("hunter2", 7, "hunter3", 7));
CHECK(!kr::proto::constTimeEq("hunter2", 7, "hunter", 6));
CHECK(!kr::proto::constTimeEq("", 0, "x", 1));
CHECK(kr::proto::constTimeEq("", 0, "", 0));
CHECK(!kr::proto::constTimeEq("hunter22", 8, "hunter2", 7)); // prefix is not a match
}
CASE("every button on the controller page is a chord the firmware accepts");
{
// The page's special keys carry their chord in the wire format itself, as
// data-chord="<mods>:<usages>". That is what keeps the page free of chord logic --
// and what makes a typo in one of them a button that either does nothing or, worse,
// sends a different key than the label on it promises. Nothing on the browser side
// would ever notice, so every one goes through the real parser here.
std::string html = readFile("src/web_page.h");
if (html.empty()) html = readFile("../src/web_page.h");
CHECK(!html.empty());
int buttons = 0, accepted = 0;
const std::string needle = "data-chord=\"";
size_t pos = 0;
while ((pos = html.find(needle, pos)) != std::string::npos) {
const size_t begin = pos + needle.size();
const size_t end = html.find('"', begin);
if (end == std::string::npos) break;
const std::string wire = "Y=" + html.substr(begin, end - begin);
Message m;
kr::proto::initMessage(m);
if (kr::proto::parse(wire.c_str(), wire.size(), m) == Cmd::Chord &&
(m.mods != 0 || m.nUsages > 0)) {
accepted++;
}
buttons++;
pos = end;
}
CHECK(buttons >= 8); // a broken extraction must not read as a pass
CHECK_EQ(buttons, accepted);
// The window-management keys the README documents. Asserted by their bytes so a
// silent rename cannot leave the documentation describing a button that is gone.
const std::string gui = std::string("08:");
CHECK_EQ((int)kr::kModLgui, 0x08); // the page's "08" must mean what this says
CHECK(html.find("data-chord=\"" + gui + "50\"") != std::string::npos); // Super+Left
CHECK(html.find("data-chord=\"" + gui + "4f\"") != std::string::npos); // Super+Right
CHECK(html.find("data-chord=\"" + gui + "4b\"") != std::string::npos); // Super+PageUp
}
CASE("every chord button sends the combination printed on it");
{
// The test above proves each chord PARSES, which is not the same claim: an
// Alt+Tab button whose bytes were Ctrl+Alt+A parses perfectly, is armed-gated,
// arrives intact, and does something else entirely. Nothing in the browser would
// ever notice. So each labelled button is pinned to the combination its label
// promises, by modifier byte and usage id.
struct Expected { const char *label; const char *wire; uint8_t mods; uint8_t usage; };
const Expected expected[] = {
{"Ctrl+Alt+F1", "05:3a", kr::kModLctrl | kr::kModLalt, 0x3a},
{"Ctrl+Alt+F2", "05:3b", kr::kModLctrl | kr::kModLalt, 0x3b},
{"Ctrl+Alt+F3", "05:3c", kr::kModLctrl | kr::kModLalt, 0x3c},
{"Ctrl+Alt+F4", "05:3d", kr::kModLctrl | kr::kModLalt, 0x3d},
{"Alt+Tab", "04:2b", kr::kModLalt, kr::kUsageTab},
{"Win", "08:", kr::kModLgui, 0x00},
{"PrtSc", "00:46", 0x00, 0x46},
{"Menu", "00:65", 0x00, 0x65},
{"Super+Left", "08:50", kr::kModLgui, 0x50},
{"Super+Right", "08:4f", kr::kModLgui, 0x4f},
{"Super+PageUp", "08:4b", kr::kModLgui, 0x4b},
};
std::string html = readFile("src/web_page.h");
if (html.empty()) html = readFile("../src/web_page.h");
CHECK(!html.empty());
for (const auto &e : expected) {
const std::string attr = std::string("data-chord=\"") + e.wire + "\"";
CHECK(html.find(attr) != std::string::npos);
const std::string wire = std::string("Y=") + e.wire;
Message m;
CHECK(kr::proto::parse(wire.c_str(), wire.size(), m) == Cmd::Chord);
if (m.mods != e.mods || m.usages[0] != e.usage ||
(e.usage != 0 && m.nUsages != 1)) {
std::printf(" %s is on the wire as %s, which is not what it says\n",
e.label, wire.c_str());
}
CHECK_EQ(m.mods, e.mods);
CHECK_EQ(e.usage == 0 ? 0 : m.usages[0], e.usage);
CHECK(e.usage == 0 ? m.nUsages == 0 : m.nUsages == 1);
}
// Alt+Tab specifically, because it was wrong once: LAlt alone with Tab, and no
// Ctrl anywhere near it -- Ctrl+Alt+something is a VT switch or a panic key.
CHECK(kr::kModLctrl == 0x01 && kr::kModLalt == 0x04);
CHECK(html.find("data-chord=\"05:04\"") == std::string::npos);
}
CASE("the page's text constants are the board's text constants");
{
// The page splits a paste by kTextFrameMax and refuses more than kTextJobCap,
// using numbers it has to keep in step with config.h by hand. If they drift, the
// board answers E8 in the middle of somebody's paste.
std::string html = readFile("src/web_page.h");
if (html.empty()) html = readFile("../src/web_page.h");
CHECK(!html.empty());
char needle[64];
snprintf(needle, sizeof(needle), "const TEXT_FRAME_MAX = %u;",
(unsigned)kr::kTextFrameMax);
CHECK(html.find(needle) != std::string::npos);
snprintf(needle, sizeof(needle), "const TEXT_JOB_MAX = %u;", (unsigned)kr::kTextJobCap);
CHECK(html.find(needle) != std::string::npos);
// And the page must actually use the P= command the firmware parses.
CHECK(html.find("\"P=\" + TEXT_PACE_MS") != std::string::npos);
}
return report("proto");
}