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
+81
View File
@@ -0,0 +1,81 @@
; keyrelay -- WiFi-controlled USB HID keyboard bridge for the M5Stack AtomS3 Lite.
;
; The board is an ESP32-S3FN8 (8 MB flash, no PSRAM) with NO USB-to-UART bridge:
; the USB-C connector is wired straight to the ESP32-S3 native USB (GPIO19/20), and
; those pads belong to ONE peripheral at a time:
;
; ARDUINO_USB_MODE=1 -> built-in USB-Serial/JTAG (console only, cannot type)
; ARDUINO_USB_MODE=0 -> USB-OTG + TinyUSB (HID keyboard + CDC console)
;
; That single-port fact is the whole reason the control path is WiFi: the board can
; be attached to exactly one machine over USB, so the controller on the laptop has to
; reach it over the network instead. See README "Why the control path is WiFi".
;
; Datasheet: https://docs.m5stack.com/en/core/AtomS3-Lite
[platformio]
default_envs = bridge
[usb]
; The core USB library compiles its MSC/FS sources together with the HID ones, and
; those include FS.h / vfs_api.h, which LDF will not resolve by itself (it tries the
; registry and fails). Only the include path is needed: nothing here references the
; MSC/FS symbols, so the linker drops those objects again. Carried over from the
; jiggle firmware on the same board.
fs_include = -I${platformio.packages_dir}/framework-arduinoespressif32/libraries/FS/src
[env]
platform = espressif32@8.0.0
board = esp32-s3-devkitc-1
framework = arduino
; Nominal for USB CDC (the USB link ignores baud); 921600 is what this board takes.
upload_speed = 921600
monitor_speed = 115200
monitor_filters = esp32_exception_decoder, time
; WiFi + TinyUSB + the embedded controller page do not fit the default 1.3 MB app
; slot of the standard two-OTA layout. This board has no OTA path in this project --
; it is reflashed over USB, always -- so one 3 MB app partition is the right trade.
board_build.partitions = huge_app.csv
lib_deps =
adafruit/Adafruit NeoPixel@^1.15.0
; ---------------------------------------------------------------------------
; Default: the full bridge. The board is a composite USB HID keyboard + USB serial
; on the target, a web server and WebSocket endpoint on the network.
; ---------------------------------------------------------------------------
[env:bridge]
build_flags =
${usb.fs_include}
-DARDUINO_USB_MODE=0
; CDC_ON_BOOT=0 is what makes the Logitech identity possible: with 1,
; cores/esp32/main.cpp calls USB.begin() from app_main() before setup() runs, the
; first tinyusb_init() fixes the VID/PID/strings as Espressif's, and every
; USB.VID()/productName() setter afterwards is a no-op. With 0 this environment
; starts the stack itself, after setting the identity (src/main.cpp: kUsb*). The
; console is unaffected: main.cpp owns a USBCDC instance, so /dev/ttyACM0 works.
-DARDUINO_USB_CDC_ON_BOOT=0
; Optional build-time station credentials, so a first bring-up can skip the serial
; console. Quoting is part of the macro: -D KR_STA_SSID='"MyNet"'.
; Prefer the `wifi <ssid> <pass>` console command: it stores into NVS and keeps
; the passphrase out of the image and out of shell history.
; -D KR_STA_SSID='" ssid "'
; -D KR_STA_PASS='" passphrase "'
build_unflags =
; esp32-s3-devkitc-1 pins ARDUINO_USB_MODE=1 in its board JSON; drop it so the
; value above is the only one on the command line.
-DARDUINO_USB_MODE=1
; ---------------------------------------------------------------------------
; Diagnostic: no USB-OTG, so no HID -- the board cannot type, but the whole control
; plane (WiFi, page, passphrase, WebSocket, LED, button) is live. Use it to separate
; "the network side is broken" from "the HID side is broken" without rebuilding.
; Arming refuses with E5 here, on purpose.
; ---------------------------------------------------------------------------
[env:console]
build_flags =
${usb.fs_include}
-DARDUINO_USB_MODE=1
-DARDUINO_USB_CDC_ON_BOOT=1