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
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""console.py -- talk to the board's serial console (stdlib only, no pyserial).
The console lives on /dev/ttyACM0: it is the CDC interface of the same composite USB
device as the HID keyboard, so it is present on whatever machine the board is plugged
into. It is where the station network and the control passphrase are set.
python3 tools/console.py # interactive, Ctrl-] to quit
python3 tools/console.py /dev/ttyACM0 status # one command, print the reply
python3 tools/console.py wifi "My SSID" "hunter2"
python3 tools/console.py token "a-long-passphrase"
python3 tools/console.py help
The baud rate is nominal: a USB CDC link carries no signalling, so 115200 is a
formality. Do not use this to type keystrokes -- there is no such command, by design.
"""
from __future__ import annotations
import os
import select
import sys
import termios
import tty
DEFAULT_PORT = "/dev/ttyACM0"
QUIT = "\x1d" # Ctrl-]
def open_raw(port: str) -> tuple[int, list]:
fd = os.open(port, os.O_RDWR | os.O_NOCTTY)
saved = termios.tcgetattr(fd)
tty.setraw(fd)
return fd, saved
def drain(fd: int, seconds: float) -> str:
out = bytearray()
deadline_remaining = seconds
while deadline_remaining > 0:
ready, _, _ = select.select([fd], [], [], deadline_remaining)
if not ready:
break
try:
chunk = os.read(fd, 4096)
except OSError:
break
if not chunk:
break
out += chunk
deadline_remaining = 1.0 # keep reading while it keeps talking
return out.decode(errors="replace")
def main() -> int:
args = sys.argv[1:]
port = DEFAULT_PORT
if args and args[0].startswith("/dev/"):
port = args[0]
args = args[1:]
if not os.path.exists(port):
print(f"{port} does not exist -- is the board plugged in here?", file=sys.stderr)
return 1
fd, saved = open_raw(port)
try:
if args:
os.write(fd, (" ".join(args) + "\n").encode())
sys.stdout.write(drain(fd, 2.0))
sys.stdout.flush()
return 0
print(f"{port} -- interactive console, Ctrl-] to quit, `help` for commands")
stdin_fd = sys.stdin.fileno()
while True:
ready, _, _ = select.select([stdin_fd, fd], [], [], 0.2)
if stdin_fd in ready:
char = os.read(stdin_fd, 1)
if not char or char == QUIT.encode():
break
os.write(fd, char)
if fd in ready:
sys.stdout.write(drain(fd, 0.05))
sys.stdout.flush()
return 0
finally:
termios.tcsetattr(fd, termios.TCSAFLUSH, saved)
os.close(fd)
if __name__ == "__main__":
sys.exit(main())