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
+102
View File
@@ -0,0 +1,102 @@
/**
* keyrelay -- build-time defaults.
*
* Everything here is a DEFAULT. The values stored in NVS win at runtime, so the
* network and the control passphrase can be changed from the serial console
* without reflashing (`wifi`, `ap`, `token` -- see README "First bring-up").
*
* Nothing secret belongs in this file. The default passphrase is deliberately
* weak and loudly announced as such on the serial console and in the web page,
* because a keyboard that anyone on the LAN can arm is an open door.
*/
#pragma once
#include <stdint.h>
#include <stddef.h>
namespace kr {
constexpr const char kFirmwareName[] = "keyrelay";
constexpr const char kFirmwareVersion[] = "1.0.0";
// ---------------------------------------------------------------------------
// Network
// ---------------------------------------------------------------------------
// Control plane only: the keystrokes travel WiFi -> board -> USB. The target
// machine provides nothing but 5 V and a USB port, and never sees a network.
constexpr uint16_t kHttpPort = 80;
constexpr char kHostname[] = "keyrelay"; // http://keyrelay.local
// Fallback access point, started whenever the station interface is not up, so
// the board is reachable with no infrastructure at all (air-gapped target).
constexpr char kApSsid[] = "keyrelay-ctl";
constexpr char kApPass[] = "keyrelay-ctl-pass"; // >= 8 chars, WPA2. CHANGE IT.
constexpr char kApGw[] = "192.168.4.1";
// Build-time station credentials (optional, still only a default). Override with
// pio run -e bridge -D KR_STA_SSID='"MyNet"' -D KR_STA_PASS='"hunter2"'
// without ever committing them.
#ifndef KR_STA_SSID
#define KR_STA_SSID ""
#endif
#ifndef KR_STA_PASS
#define KR_STA_PASS ""
#endif
constexpr uint32_t kStaTimeoutMs = 8000; // give the association attempt this long at boot
constexpr uint32_t kStaRetryMs = 20000; // ... then retry in the background at this rate
// ---------------------------------------------------------------------------
// Control passphrase
// ---------------------------------------------------------------------------
// Checked on every WebSocket connection and on /status. The page cannot arm the
// bridge without it, and nothing reaches the target until the bridge is armed.
constexpr char kDefaultToken[] = "changeme";
constexpr size_t kTokenMax = 32;
constexpr uint8_t kTokenMaxFails = 5; // then stop evaluating the token ...
constexpr uint32_t kTokenLockMs = 30000; // ... for this long
// ---------------------------------------------------------------------------
// Keystroke path
// ---------------------------------------------------------------------------
constexpr size_t kMaxKeysPerMessage = 8; // protocol cap (HID carries 6)
constexpr uint32_t kCadHoldMs = 60; // how long Ctrl+Alt+Del is held down
constexpr uint32_t kChordHoldMs = 40; // ditto for the other special chords
constexpr uint32_t kMinReportGapUs = 1000; // 1 kHz floor: the host polls at 1 kHz
constexpr size_t kReportQueueDepth = 48; // bursts from a fast typist
// ---------------------------------------------------------------------------
// Pasted text
// ---------------------------------------------------------------------------
// `P=<ms>:<hex>` (src/text.h) types a run of characters that a human pasted and
// then deliberately sent. The board owns the character map and the pacing: a
// controller's clock, arriving over WiFi in whatever order the network delivers
// it, is not a keyboard timing source. The controller asks for a pace; the board
// keeps it.
//
// One controller frame is still one command, so a frame carries at most
// kTextFrameMax characters and a long paste arrives as several frames. The board
// holds whatever it has been given and says so in `typing` in the status frame,
// which is how a controller knows it may send the next piece.
constexpr size_t kTextFrameMax = 120; // characters per P= frame
constexpr size_t kTextJobCap = 2048; // characters held for one paste job
constexpr uint16_t kTextPeriodMsDefault = 12; // ms per character when asked for 0
constexpr uint16_t kTextPeriodMaxMs = 250; // slowest pace a controller may ask for
constexpr uint16_t kTextHoldMaxMs = 6; // how long one character is held down
// Safety: a bridge that has heard nothing from its controller for this long is
// a bridge that should not be typing. Arming never survives it (laptop slept,
// cable unplugged, browser killed).
constexpr uint32_t kNoClientTimeoutMs = 8000;
// Status heartbeat to the controller, and the interval of the held-key watchdog.
constexpr uint32_t kStatusIntervalMs = 1000;
// ---------------------------------------------------------------------------
// Control-server buffers
// ---------------------------------------------------------------------------
constexpr size_t kHttpHeaderCap = 1536; // reject a request line bigger than this
constexpr size_t kWsMessageCap = 256; // one controller frame is a command, not a payload
constexpr uint32_t kAuthTimeoutMs = 5000; // a new socket must authenticate this fast
} // namespace kr