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())
+703
View File
@@ -0,0 +1,703 @@
#!/usr/bin/env python3
"""relayctl -- talk to a keyrelay board from a terminal.
Stdlib only (socket, hashlib, base64, json): the board's protocol is a WebSocket of
tiny text commands, and the point of a keyboard bridge is that it needs nothing
installed to be useful.
relayctl.py --host keyrelay.local status
relayctl.py --host 192.168.4.1 arm
relayctl.py --host 10.2.1.99 key Enter # one key, press and release
relayctl.py --host 10.2.1.99 cad # Ctrl+Alt+Del, with --yes
relayctl.py --host keyrelay.local listen # print every status heartbeat
relayctl.py --host keyrelay.local disarm
wl-paste | relayctl.py paste --yes # type what is on the clipboard
The passphrase is taken from --token, then $KEYRELAY_TOKEN. It is never written to
disk and never put in a URL (the /status HTTP endpoint takes it as a query parameter
because curl is the point of that endpoint; this tool uses the WebSocket instead).
`paste` is the one command that types more than a single key, because sending text a
human copied is a thing a keyboard bridge is for. It is gated accordingly: --yes is
required, the board is armed by that same deliberate invocation (arming cannot outlive
a controller, so no separate `arm` command could have done it), the text is never
echoed back, and the run disarms again afterwards. What is still absent is a script or
macro facility: the board cannot store a sequence, cannot be told to type later, and
nothing here replays one. See README "What this deliberately is not".
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import os
import socket
import struct
import sys
import time
DEFAULT_PORT = 80
WS_PATH = "/ws"
# The whole point of `keys` is verifying the keystroke path in one armed session.
# A cap keeps it that: enough for a test vector, not a way to type a paragraph.
MAX_SESSION_KEYS = 16
# Pasted text. All three numbers are the board's, not this tool's -- keep them in
# step with src/config.h (kTextFrameMax, kTextJobCap) or the board starts refusing
# frames this tool thought were legal.
TEXT_FRAME_MAX = 120 # characters per P= frame
MAX_PASTE_CHARS = 2048 # characters one run holds on the board
DEFAULT_TEXT_PACE_MS = 12 # what the board uses when asked for 0
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
OP_TEXT, OP_PING, OP_PONG, OP_CLOSE = 0x1, 0x9, 0xA, 0x8
# KeyboardEvent.code -> HID usage id, the same table the controller page carries
# (src/web_page.h). Names here are those codes, so `key ShiftLeft` and
# `key BracketRight` work the way they do in the browser.
CODE_TO_USAGE = {
"KeyA": 0x04, "KeyB": 0x05, "KeyC": 0x06, "KeyD": 0x07, "KeyE": 0x08, "KeyF": 0x09,
"KeyG": 0x0A, "KeyH": 0x0B, "KeyI": 0x0C, "KeyJ": 0x0D, "KeyK": 0x0E, "KeyL": 0x0F,
"KeyM": 0x10, "KeyN": 0x11, "KeyO": 0x12, "KeyP": 0x13, "KeyQ": 0x14, "KeyR": 0x15,
"KeyS": 0x16, "KeyT": 0x17, "KeyU": 0x18, "KeyV": 0x19, "KeyW": 0x1A, "KeyX": 0x1B,
"KeyY": 0x1C, "KeyZ": 0x1D,
"Digit1": 0x1E, "Digit2": 0x1F, "Digit3": 0x20, "Digit4": 0x21, "Digit5": 0x22,
"Digit6": 0x23, "Digit7": 0x24, "Digit8": 0x25, "Digit9": 0x26, "Digit0": 0x27,
"Enter": 0x28, "Escape": 0x29, "Backspace": 0x2A, "Tab": 0x2B, "Space": 0x2C,
"Minus": 0x2D, "Equal": 0x2E, "BracketLeft": 0x2F, "BracketRight": 0x30,
"Backslash": 0x31, "IntlBackslash": 0x32, "Semicolon": 0x33, "Quote": 0x34,
"Backquote": 0x35, "Comma": 0x36, "Period": 0x37, "Slash": 0x38, "CapsLock": 0x39,
"F1": 0x3A, "F2": 0x3B, "F3": 0x3C, "F4": 0x3D, "F5": 0x3E, "F6": 0x3F, "F7": 0x40,
"F8": 0x41, "F9": 0x42, "F10": 0x43, "F11": 0x44, "F12": 0x45,
"PrintScreen": 0x46, "ScrollLock": 0x47, "Pause": 0x48, "Insert": 0x49,
"Home": 0x4A, "PageUp": 0x4B, "Delete": 0x4C, "End": 0x4D, "PageDown": 0x4E,
"ArrowRight": 0x4F, "ArrowLeft": 0x50, "ArrowDown": 0x51, "ArrowUp": 0x52,
"NumLock": 0x53,
"NumpadDivide": 0x54, "NumpadMultiply": 0x55, "NumpadSubtract": 0x56,
"NumpadAdd": 0x57, "NumpadEnter": 0x58, "Numpad1": 0x59, "Numpad2": 0x5A,
"Numpad3": 0x5B, "Numpad4": 0x5C, "Numpad5": 0x5D, "Numpad6": 0x5E, "Numpad7": 0x5F,
"Numpad8": 0x60, "Numpad9": 0x61, "Numpad0": 0x62, "NumpadDecimal": 0x63,
"IntlRo": 0x64, "ContextMenu": 0x65, "NumpadEqual": 0x67,
"F13": 0x68, "F14": 0x69, "F15": 0x6A, "F16": 0x6B, "F17": 0x6C, "F18": 0x6D,
"F19": 0x6E, "F20": 0x6F, "F21": 0x70, "F22": 0x71, "F23": 0x72, "F24": 0x73,
}
MOD_CODE = {
"ControlLeft": 0x01, "ShiftLeft": 0x02, "AltLeft": 0x04, "MetaLeft": 0x08,
"ControlRight": 0x10, "ShiftRight": 0x20, "AltRight": 0x40, "MetaRight": 0x80,
}
class RelayError(RuntimeError):
pass
# ---------------------------------------------------------------------------
# Text -> keystrokes
# ---------------------------------------------------------------------------
def normalize_paste(text: str) -> str:
"""CRLF and a lone CR both become LF.
The board maps \r to Enter, so an unnormalised Windows paste would type two
Enters per line. The board cannot fix this itself: a CRLF pair can straddle a
frame boundary, so normalising belongs to whoever holds the whole text.
"""
return text.replace("\r\n", "\n").replace("\r", "\n")
def untypeable(text: str) -> list[str]:
"""Characters with no key on a US-ANSI keyboard.
Tab, newline and printable ASCII are what the board's character map can reach.
Anything else -- curly quotes, em dashes, UTF-8 accents -- is reported instead of
guessed at, because a paste that quietly loses a character is worse than one that
refuses to go.
"""
return [ch for ch in text
if not (ord(ch) == 9 or ord(ch) == 10 or 32 <= ord(ch) <= 126)]
def text_frame(delay_ms: int, chunk: str) -> str:
"""One `P=<ms>:<hex>` command, byte-identical to proto.h's formatText()."""
return f"P={delay_ms}:{chunk.encode('utf-8').hex()}"
# ---------------------------------------------------------------------------
# WebSocket, the smallest useful slice
# ---------------------------------------------------------------------------
def expect_accept(key: str) -> str:
"""Sec-WebSocket-Accept for a Sec-WebSocket-Key (RFC 6455 1.3)."""
digest = hashlib.sha1((key + GUID).encode()).digest()
return base64.b64encode(digest).decode()
def encode_frame(opcode: int, payload: bytes = b"") -> bytes:
"""Client frames are always masked (RFC 6455 5.1: a server must reject others)."""
out = bytearray([0x80 | opcode])
n = len(payload)
if n < 126:
out.append(0x80 | n)
elif n <= 0xFFFF:
out.append(0x80 | 126)
out += struct.pack(">H", n)
else:
out.append(0x80 | 127)
out += struct.pack(">Q", n)
mask = os.urandom(4)
out += mask
out += bytes(b ^ mask[i & 3] for i, b in enumerate(payload))
return bytes(out)
def mask_payload(payload: bytes, mask: bytes) -> bytes:
"""XOR with the repeating 4-byte mask key. Its own inverse, as the spec says."""
return bytes(b ^ mask[i & 3] for i, b in enumerate(payload))
class Receiver:
"""Incremental frame decoder for the unmasked frames the board sends."""
def __init__(self) -> None:
self.buf = bytearray()
def feed(self, data: bytes):
"""Yield (opcode, payload) for every complete frame in `data`."""
self.buf.extend(data)
while True:
if len(self.buf) < 2:
return
b0, b1 = self.buf[0], self.buf[1]
if b1 & 0x80:
raise RelayError("board sent a masked frame (RFC 6455 forbids that)")
if not b0 & 0x80:
raise RelayError("board sent a fragmented frame")
length = b1 & 0x7F
offset = 2
if length == 126:
if len(self.buf) < offset + 2:
return
length = struct.unpack(">H", bytes(self.buf[offset:offset + 2]))[0]
offset += 2
elif length == 127:
if len(self.buf) < offset + 8:
return
length = struct.unpack(">Q", bytes(self.buf[offset:offset + 8]))[0]
offset += 8
if len(self.buf) < offset + length:
return
payload = bytes(self.buf[offset:offset + length])
del self.buf[:offset + length]
yield b0 & 0x0F, payload
class Relay:
def __init__(self, host: str, port: int = DEFAULT_PORT, token: str = "",
timeout: float = 5.0) -> None:
self.host = host
self.timeout = timeout
self.token = token
self.sock = socket.create_connection((host, port), timeout)
self.receiver = Receiver()
key = base64.b64encode(os.urandom(16)).decode()
request = (
f"GET {WS_PATH} HTTP/1.1\r\n"
f"Host: {host}\r\n"
f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n"
)
self.sock.sendall(request.encode())
head = b""
while b"\r\n\r\n" not in head:
chunk = self.sock.recv(4096)
if not chunk:
raise RelayError("server closed during the handshake")
head += chunk
if len(head) > 16384:
raise RelayError("handshake response too large")
status_line = head.split(b"\r\n", 1)[0].decode(errors="replace")
# Header NAMES are case-insensitive; header VALUES are not. Lowercasing the
# whole block -- as the first version of this did -- lowercases the base64
# accept hash too, and base64 is case-sensitive, so every handshake looked
# broken while the board was answering correctly. Parse into fields instead.
fields: dict[str, str] = {}
for line in head.split(b"\r\n\r\n", 1)[0].decode("latin-1").split("\r\n")[1:]:
name, sep, value = line.partition(":")
if sep:
fields[name.strip().lower()] = value.strip()
if " 101 " not in status_line + " ":
detail = "refused"
if "401" in status_line:
detail = "passphrase required (this endpoint does not take one)"
elif "503" in status_line:
detail = "another controller already holds the bridge"
raise RelayError(f"handshake failed: {status_line} ({detail})")
want = expect_accept(key)
got = fields.get("sec-websocket-accept", "")
if got != want:
raise RelayError(f"handshake failed: Sec-WebSocket-Accept is {got!r}, "
f"expected {want!r}")
self._send("T" + self.token)
reply = self._await_reply()
if reply != "OK":
raise RelayError(f"authentication refused: {reply or 'no reply'}")
# -- plumbing -----------------------------------------------------------
def _send(self, text: str) -> None:
self.sock.sendall(encode_frame(OP_TEXT, text.encode()))
def _poll(self, want_status: bool, timeout: float):
deadline = time.time() + timeout
while time.time() < deadline:
for opcode, payload in self._read_any(max(0.0, deadline - time.time())):
if opcode == OP_PING:
self.sock.sendall(encode_frame(OP_PONG, payload))
elif opcode == OP_CLOSE:
raise RelayError("board closed the connection")
elif opcode == OP_TEXT:
text = payload.decode(errors="replace")
if text.startswith("S{"):
if want_status:
return json.loads(text[1:])
elif text == "OK":
if not want_status:
return text
elif text.startswith("E"):
raise RelayError(f"board said {text!r}")
else:
print(f"board: {text}", file=sys.stderr)
if not want_status:
return None
return None
def _read_any(self, timeout: float):
self.sock.settimeout(max(0.05, timeout))
try:
data = self.sock.recv(4096)
except socket.timeout:
return []
if not data:
raise RelayError("connection closed by the board")
return self.receiver.feed(data)
def _await_reply(self, timeout: float = 4.0):
return self._poll(want_status=False, timeout=timeout)
# -- commands -----------------------------------------------------------
def wait_idle(self, timeout: float) -> bool:
"""Poll until the board says it has nothing left to type, or the time is up.
Keeps the connection talking while it waits: a controller that says nothing
for kNoClientTimeoutMs is one the board disarms, and a long paste would
otherwise stop itself halfway through. `typing` in the status frame is the
board's own count, so this is flow control rather than a guess at timing.
"""
deadline = time.time() + timeout
next_beat = time.time() + 2.0
while time.time() < deadline:
if time.time() >= next_beat:
self._send("K")
next_beat = time.time() + 2.0
got = self._poll(want_status=True, timeout=0.25)
if got is not None and not got.get("typing"):
return True
return False
def type_text(self, text: str, delay_ms: int = 0, show: bool = True):
"""Type text, one frame per TEXT_FRAME_MAX characters, board-paced.
Returns (characters confirmed typed, seconds elapsed). A frame the board
refuses raises RelayError out of _poll, so a run that cannot continue does not
quietly carry on with what is left of the text.
"""
pace = delay_ms or DEFAULT_TEXT_PACE_MS
chunks = [text[i:i + TEXT_FRAME_MAX] for i in range(0, len(text), TEXT_FRAME_MAX)]
started = time.time()
sent = confirmed = 0
for chunk in chunks:
self._send(text_frame(delay_ms, chunk))
sent += len(chunk)
# The chunk's own typing time, plus a heartbeat's worth of slack.
if self.wait_idle(len(chunk) * (pace + 4) / 1000.0 + 4.0):
confirmed = sent
if show:
print(f" {confirmed}/{len(text)} characters", end="\r", flush=True)
if confirmed != sent:
break
if show and chunks:
print(" " * 34, end="\r" if confirmed == len(text) else "\n", flush=True)
return confirmed, time.time() - started
def _drain(self, seconds: float = 0.4) -> None:
"""Throw away whatever is already buffered.
The board heartbeats status at 1 Hz whether or not anyone asked, and the key
helpers do not read while they type, so seconds of stale frames pile up in the
socket. Reading a status without draining first returns one of *those* -- which
is how `disarm` looked like it had failed while printing `armed YES`: the frame
shown was a heartbeat sent before the command. Pending error frames are still
raised, because an E-frame is never stale.
"""
deadline = time.time() + seconds
while time.time() < deadline:
for opcode, payload in self._read_any(min(0.1, max(0.01, deadline - time.time()))):
if opcode == OP_PING:
# Answer it the way _poll does: an unanswered ping is a link the
# board may decide is dead, and this method must not be the one
# thing that breaks the keepalive (it called methods that do not
# exist, which only ever showed up on a session over 15 s long).
self.sock.sendall(encode_frame(OP_PONG, payload))
elif opcode == OP_CLOSE:
raise RelayError("board closed the connection")
elif opcode == OP_TEXT:
text = payload.decode("utf-8", "replace")
if text.startswith("E"):
raise RelayError(f"board said {text!r}")
def status(self) -> dict:
self._drain()
self._send("H")
got = self._poll(want_status=True, timeout=self.timeout)
if got is None:
raise RelayError("no status reply")
return got
def _settle(self, want_armed: bool, timeout: float = 2.0) -> dict:
"""Poll until the board reports the arming we asked for."""
deadline = time.time() + timeout
last: dict | None = None
while time.time() < deadline:
self._drain(0.0)
got = self._poll(want_status=True, timeout=0.25)
if got is not None:
last = got
if bool(got.get("armed")) == want_armed:
return got
return last or {}
def arm(self) -> dict:
self._drain()
self._send("A1")
state = self._settle(True)
if not state.get("armed"):
raise RelayError("the board did not arm: " +
", ".join(f"{k}={v}" for k, v in state.items()))
return state
def disarm(self) -> dict:
self._drain()
self._send("R") # keys up first, then disarm
self._send("A0")
state = self._settle(False)
if state.get("armed"):
raise RelayError("the board did not disarm -- use the button on the board")
return state
def release(self) -> None:
self._send("R")
def state(self, mods: int, usages) -> None:
body = ",".join(f"{u:02x}" for u in usages)
self._send(f"S={mods:02x}:{body}")
def one_key(self, usage: int, hold_ms: int) -> None:
self.state(0x00, [usage])
time.sleep(hold_ms / 1000.0)
self.state(0x00, [])
def chord(self, mods: int, usages) -> None:
body = ",".join(f"{u:02x}" for u in usages)
self._send(f"Y={mods:02x}:{body}")
def listen(self, seconds: float) -> None:
"""Print heartbeats. Sends keepalives: a silent client is one the board disarms
after kNoClientTimeoutMs, and watching must not change what is watched."""
deadline = time.time() + seconds
next_beat = time.time() + 2.0
while time.time() < deadline:
if time.time() >= next_beat:
self._send("K")
next_beat = time.time() + 2.0
try:
for opcode, payload in self._read_any(1.0):
if opcode == OP_PING:
self.sock.sendall(encode_frame(OP_PONG, payload))
elif opcode == OP_TEXT:
text = payload.decode(errors="replace")
if text.startswith("S{"):
s = json.loads(text[1:])
print(f" net={s.get('net'):4s} ip={s.get('ip'):15s} "
f"usb={s.get('usb')} armed={s.get('armed')} "
f"keys={s.get('keys')} chords={s.get('chords')} "
f"drop={s.get('drop')} leds=0x{s.get('leds'):02x}")
else:
print(f" {text}")
elif opcode == OP_CLOSE:
return
except socket.timeout:
continue
def close(self) -> None:
try:
self.sock.sendall(encode_frame(OP_CLOSE))
except OSError:
pass
self.sock.close()
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def resolve_key(name: str) -> int:
if name.lower().startswith("0x"):
value = int(name, 16)
if not 0 < value <= 0xFF:
raise RelayError("a usage id is 01..ff")
return value
if name in CODE_TO_USAGE:
return CODE_TO_USAGE[name]
known = ", ".join(sorted(CODE_TO_USAGE)[:12])
raise RelayError(f"unknown key {name!r} (try 0x28, or one of: {known}, ...)")
def describe(status: dict) -> str:
lines = [
f" firmware {status.get('v')}",
f" uptime {status.get('up', 0)} s",
f" network {status.get('net')} ssid={status.get('ssid') or '-'} "
f"ip={status.get('ip') or '-'}",
f" target {'attached over USB' if status.get('usb') else 'NOT ATTACHED'}",
f" armed {'YES -- keystrokes are being forwarded' if status.get('armed') else 'no'}",
f" counters keys={status.get('keys')} chords={status.get('chords')} "
f"dropped={status.get('drop')}",
f" text {status.get('typed', 0)} characters typed"
+ (f", {status['typing']} still to go" if status.get('typing') else ""),
f" leds caps={bool(status.get('leds', 0) & 2)} "
f"num={bool(status.get('leds', 0) & 1)}",
]
if status.get("dflt"):
lines.append(" WARNING the board still uses the default passphrase")
if status.get("roll"):
lines.append(" NOTE more than six keys are down: ErrorRollOver is out")
return "\n".join(lines)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--host", default="keyrelay.local",
help="board address (default keyrelay.local; on its own AP use "
"192.168.4.1)")
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
ap.add_argument("--token", default=os.environ.get("KEYRELAY_TOKEN", ""),
help="control passphrase (or $KEYRELAY_TOKEN)")
ap.add_argument("--timeout", type=float, default=5.0)
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("status", help="print what the board reports")
sub.add_parser("arm", help="arm the bridge (nothing types before this)")
sub.add_parser("disarm", help="release every key and disarm")
sub.add_parser("release", help="release every key, stay armed")
p_cad = sub.add_parser("cad", help="Ctrl+Alt+Del -- needs --yes, and a thought")
p_cad.add_argument("--yes", action="store_true",
help="acknowledge that this reboots a Linux virtual terminal")
p_key = sub.add_parser("key", help="press and release exactly one key")
p_key.add_argument("name", help="key code name (Enter, KeyA, F7...) or 0x28")
p_key.add_argument("--hold", type=int, default=40, metavar="MS",
help="how long to hold it (default 40)")
p_chord = sub.add_parser("chord", help="press one chord: mods plus usages")
p_chord.add_argument("mods", help="modifier byte, e.g. 05 = LCtrl|LAlt")
p_chord.add_argument("usages", nargs="*", help="usage ids, e.g. 4c")
p_listen = sub.add_parser("listen", help="print status heartbeats")
p_listen.add_argument("seconds", nargs="?", type=float, default=20.0)
p_paste = sub.add_parser(
"paste",
help="type text a human is sending (e.g. `wl-paste | relayctl paste --yes`)")
p_paste.add_argument("text", nargs="?",
help="the text; omit it, or give '-', to read stdin")
p_paste.add_argument("--delay", type=int, default=0, metavar="MS",
help="milliseconds per character (0 = the board's default)")
p_paste.add_argument("--strip", action="store_true",
help="drop characters this keyboard has no key for, instead "
"of refusing the paste")
p_paste.add_argument("--trailing-enter", action="store_true",
help="press Enter once the text has been typed")
p_paste.add_argument("--keep-armed", action="store_true",
help="leave the bridge armed afterwards")
p_paste.add_argument("--yes", action="store_true",
help="acknowledge that this types into the target")
p_keys = sub.add_parser(
"keys",
help="verification session: arm, press these keys, disarm again")
p_keys.add_argument("names", nargs="+", metavar="KEY",
help=f"key names or 0xNN, at most {MAX_SESSION_KEYS}")
p_keys.add_argument("--gap", type=int, default=120, metavar="MS",
help="pause between keys (default 120)")
p_keys.add_argument("--hold", type=int, default=40, metavar="MS",
help="how long each key is held (default 40)")
p_keys.add_argument("--keep-armed", action="store_true",
help="stay armed afterwards (the board still disarms on "
"silence 8 s after this process exits)")
args = ap.parse_args()
if not args.token:
print("no passphrase: --token or $KEYRELAY_TOKEN", file=sys.stderr)
return 2
# Checked before anything is contacted: the acknowledgement is the point, and
# finding out you forgot it should not require a reachable board, a token and a
# connection that then has to be torn down again.
if args.cmd == "paste" and not args.yes:
print("paste types a whole run of characters into the machine the board is "
"plugged into.\nRe-run with --yes if a human means it.\n\n"
" printf 'hello\\n' | relayctl.py --host keyrelay.local paste --yes",
file=sys.stderr)
return 4
# Everything about the text that can be settled locally is settled here, before a
# socket is opened: a paste the board would refuse should fail in the terminal
# rather than half-arm a bridge and then stop.
paste_text = ""
if args.cmd == "paste":
# No argument, or '-', is the clipboard: `wl-paste | relayctl paste --yes`.
raw = sys.stdin.read() if args.text in (None, "-") else args.text
paste_text = normalize_paste(raw)
if args.trailing_enter:
paste_text += "\n"
if not paste_text:
print("nothing to type: stdin was empty and no text was given",
file=sys.stderr)
return 2
bad = untypeable(paste_text)
if bad:
if not args.strip:
first = bad[0]
print(f"{len(bad)} of {len(paste_text)} characters have no key on a "
f"US-ANSI keyboard (first is {first!r} "
f"U+{ord(first):04X}).\nRe-run with --strip to type the rest, "
f"or fix the text.", file=sys.stderr)
return 2
paste_text = "".join(ch for ch in paste_text if ord(ch) == 9
or ord(ch) == 10 or 32 <= ord(ch) <= 126)
print(f"dropped {len(bad)} character(s) with no key here")
if not paste_text:
print("nothing left to type", file=sys.stderr)
return 2
if len(paste_text) > MAX_PASTE_CHARS:
print(f"{len(paste_text)} characters is more than one run holds "
f"({MAX_PASTE_CHARS}); send it in pieces", file=sys.stderr)
return 2
try:
relay = Relay(args.host, args.port, args.token, args.timeout)
except (OSError, RelayError) as exc:
print(f"cannot use the board at {args.host}: {exc}", file=sys.stderr)
return 1
try:
if args.cmd == "status":
print(describe(relay.status()))
elif args.cmd == "arm":
relay.arm()
print("armed -- keystrokes now go to the target")
print(describe(relay.status()))
elif args.cmd == "disarm":
relay.disarm()
print("disarmed, every key released")
elif args.cmd == "release":
relay.release()
print("every key released (still armed)")
elif args.cmd == "key":
usage = resolve_key(args.name)
before = relay.status()
if not before.get("armed"):
print("the bridge is disarmed: nothing was sent. Run `arm` first.",
file=sys.stderr)
return 3
print(f"sending {args.name} (usage 0x{usage:02x}) for {args.hold} ms ...")
relay.one_key(usage, args.hold)
elif args.cmd == "chord":
mods = int(args.mods, 16)
usages = [int(u, 16) for u in args.usages]
if not usages and not mods:
print("a chord needs a modifier or a key", file=sys.stderr)
return 2
print(f"sending chord Y={mods:02x}:"
f"{','.join(f'{u:02x}' for u in usages)}")
relay.chord(mods, usages)
elif args.cmd == "cad":
if not args.yes:
print("Ctrl+Alt+Del: on a Linux virtual terminal this REBOOTS the "
"target; on Windows it is the secure attention sequence.\n"
"Re-run with --yes if that is what you meant.", file=sys.stderr)
return 4
status = relay.status()
if not status.get("armed"):
print("the bridge is disarmed: nothing was sent.", file=sys.stderr)
return 3
print("sending Ctrl+Alt+Del ...")
relay.chord(0x05, [0x4C])
elif args.cmd == "keys":
# One deliberate session in one connection: the board disarms the moment
# a controller drops (that is the safety property that makes the rest of
# this work), so arm-and-type has to be a single client's business. It is
# capped and it disarms afterwards: a session, not a script.
if len(args.names) > MAX_SESSION_KEYS:
print(f"at most {MAX_SESSION_KEYS} keys per session -- this is a "
f"verification tool, not a typer",
file=sys.stderr)
return 2
usages = [resolve_key(n) for n in args.names]
print("target: " + " ".join(
f"{n}({u:#04x})" for n, u in zip(args.names, usages)))
relay.arm()
print("armed -- LED should be solid red on the board")
for name, usage in zip(args.names, usages):
print(f" {name} ...", end=" ", flush=True)
relay.one_key(usage, args.hold)
time.sleep(args.gap / 1000.0)
print()
if args.keep_armed:
print("left armed")
else:
relay.disarm()
print("disarmed, every key released")
print(describe(relay.status()))
elif args.cmd == "paste":
text = paste_text
# Deliberately the count and not the content: what was typed is nobody's
# business, including this terminal's scrollback.
print(f"about to type {len(text)} characters into "
f"{args.host} at {args.delay or DEFAULT_TEXT_PACE_MS} ms each")
relay.arm()
print("armed -- the board's LED should be solid red")
typed, elapsed = relay.type_text(text, args.delay)
print(f"typed {typed} of {len(text)} characters in {elapsed:.1f}s")
if args.keep_armed:
print("left armed")
else:
relay.disarm()
print("disarmed, every key released")
elif args.cmd == "listen":
relay.listen(args.seconds)
except RelayError as exc:
print(str(exc), file=sys.stderr)
return 1
except KeyboardInterrupt:
print()
finally:
relay.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""to_bootloader.py -- put the AtomS3 Lite into ROM USB download mode, stdlib only.
The firmware owns the USB pads (USB-OTG / TinyUSB), so esptool cannot pulse the board
into its bootloader by itself: with an app running, `pio run -t upload` sits at
"Connecting...". The Arduino CDC implementation watches for a line-coding change to
1200 baud and responds by calling usb_persist_restart(RESTART_BOOTLOADER), which hands
the pads back to the built-in USB-Serial/JTAG and forces a download-mode boot. Setting
a tty's baud rate is exactly the SET_LINE_CODING control transfer that triggers it, so
termios is enough -- no pyserial, no GPIO0 (which this board does not break out).
python3 tools/to_bootloader.py [port] # default /dev/ttyACM0
pio run -e bridge -t upload # then flash
With no host cooperation at all: hold the top button while the board powers up and
keep holding for 3 s (the LED goes solid red). The firmware does the same thing.
"""
from __future__ import annotations
import glob
import os
import sys
import termios
import time
VENDOR_ESPRESSIF = "303a"
ROM_MARKER = "JTAG" # the ROM enumerates as "USB JTAG/serial debug unit"
def read_sysfs(path: str) -> str:
try:
with open(path) as handle:
return handle.read().strip()
except OSError:
return ""
def download_mode_port(timeout: float) -> str | None:
"""Wait for the ROM's USB-Serial/JTAG port to show up."""
deadline = time.time() + timeout
while time.time() < deadline:
for device in glob.glob("/sys/bus/usb/devices/*"):
if read_sysfs(f"{device}/idVendor") != VENDOR_ESPRESSIF:
continue
if ROM_MARKER not in read_sysfs(f"{device}/product"):
continue
for tty in glob.glob(f"{device}/*/tty/ttyACM*"):
return "/dev/" + tty.rsplit("/", 1)[-1]
time.sleep(0.2)
return None
def touch(port: str) -> None:
"""Ask the running firmware for a download-mode reboot by setting 1200 baud."""
fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
try:
# tcgetattr -> [iflag, oflag, cflag, lflag, ispeed, ospeed, cc] (indices 4
# and 5 are the speeds); setting them is what makes the kernel issue the CDC
# SET_LINE_CODING the firmware watches for at 1200 baud.
attrs = termios.tcgetattr(fd)
attrs[4] = termios.B1200
attrs[5] = termios.B1200
termios.tcsetattr(fd, termios.TCSANOW, attrs)
time.sleep(0.3)
finally:
# The chip resets underneath us; EPROTO/ENODEV here means it worked.
try:
os.close(fd)
except OSError:
pass
def main() -> int:
port = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyACM0"
if not os.path.exists(port):
print(f"{port} does not exist -- is the board plugged in?", file=sys.stderr)
return 1
print(f"touching {port} at 1200 baud ...")
try:
touch(port)
except OSError as exc:
print(f"port dropped out ({exc.strerror}) -- that is the reboot")
found = download_mode_port(timeout=10.0)
if found:
print(f"ROM download mode reached on {found}")
print("now flash with: pio run -e bridge -t upload")
return 0
print("no ROM download port appeared.", file=sys.stderr)
print("fallback: hold the top button while the board powers up and keep holding "
"for 3 s (LED solid red), then flash.", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""watch_target.py -- prove what the target actually received, on the target.
Run this on the machine the keyrelay board is plugged into. It opens the evdev node
of the board's keyboard interface and decodes every event, so verification never
depends on what happens to have focus: no terminal, no editor, no guessed timing.
python3 watch_target.py # 20 s, then a summary
python3 watch_target.py --seconds 60
python3 watch_target.py --show-all # include unknown key codes
It reads, it does not write: EV_KEY and EV_LED only, and nothing is injected.
Membership of the `input` group is enough to open /dev/input/event*. The board is
found by USB id rather than by a device-name path, so a rename or a different port
cannot break it (the default id is the Logitech G815 identity the firmware uses,
see src/main.cpp; the Espressif default is also accepted for older builds).
"""
from __future__ import annotations
import argparse
import glob
import select
import struct
import sys
import time
EVENT_FORMAT = "llHHi" # struct input_event on 64-bit Linux
EVENT_SIZE = struct.calcsize(EVENT_FORMAT)
EV_SYN, EV_KEY, EV_LED = 0x00, 0x01, 0x11
IDENTITIES = {("046d", "c33f"), ("303a", "1001")}
# evdev key code -> label. Covers everything keyrelay can put on the wire, from the
# modifier byte through the navigation cluster and the numpad.
KEY_NAMES = {
1: "Esc", 2: "1", 3: "2", 4: "3", 5: "4", 6: "5", 7: "6", 8: "7", 9: "8",
10: "9", 11: "0", 12: "Minus", 13: "Equal", 14: "Backspace", 15: "Tab",
16: "Q", 17: "W", 18: "E", 19: "R", 20: "T", 21: "Y", 22: "U", 23: "I",
24: "O", 25: "P", 26: "BracketLeft", 27: "BracketRight", 28: "Enter",
29: "LCtrl", 30: "A", 31: "S", 32: "D", 33: "F", 34: "G", 35: "H", 36: "J",
37: "K", 38: "L", 39: "Semicolon", 40: "Quote", 41: "Backquote",
42: "LShift", 43: "Backslash", 44: "Z", 45: "X", 46: "C", 47: "V", 48: "B",
49: "N", 50: "M", 51: "Comma", 52: "Period", 53: "Slash", 54: "RShift",
55: "KpMultiply", 56: "LAlt", 57: "Space", 58: "CapsLock", 59: "F1", 60: "F2",
61: "F3", 62: "F4", 63: "F5", 64: "F6", 65: "F7", 66: "F8", 67: "F9",
68: "F10", 69: "NumLock", 70: "ScrollLock", 71: "Kp7", 72: "Kp8", 73: "Kp9",
74: "KpMinus", 75: "Kp4", 76: "Kp5", 77: "Kp6", 78: "KpPlus", 79: "Kp1",
80: "Kp2", 81: "Kp3", 82: "Kp0", 83: "KpDecimal", 86: "IntlBackslash",
87: "F11", 88: "F12", 96: "KpEnter", 97: "RCtrl", 99: "PrintScreen",
100: "RAlt", 102: "Home", 103: "Up", 104: "PageUp", 105: "Left",
106: "Right", 107: "End", 108: "Down", 109: "PageDown", 110: "Insert",
111: "Delete", 119: "Pause", 125: "LSuper", 126: "RSuper", 127: "ContextMenu",
158: "KpLeftParen", 159: "KpRightParen",
# HID usages 0x68..0x73. Unnamed at first, which hid a whole verification run:
# --show-all existed but the F13..F24 row people actually test with was missing.
183: "F13", 184: "F14", 185: "F15", 186: "F16", 187: "F17", 188: "F18",
189: "F19", 190: "F20", 191: "F21", 192: "F22", 193: "F23", 194: "F24",
}
# code -> (unshifted, shifted), for reassembling what was actually typed. Anything
# not listed still shows up as a named key press, just not as a character.
TYPED = {
57: (" ", " "), 2: ("1", "!"), 3: ("2", "@"), 4: ("3", "#"), 5: ("4", "$"),
6: ("5", "%"), 7: ("6", "^"), 8: ("7", "&"), 9: ("8", "*"), 10: ("9", "("),
11: ("0", ")"), 12: ("-", "_"), 13: ("=", "+"), 26: ("[", "{"), 27: ("]", "}"),
39: (";", ":"), 40: ("'", '"'), 41: ("`", "~"), 43: ("\\", "|"),
51: (",", "<"), 52: (".", ">"), 53: ("/", "?"), 86: ("\\", "|"),
15: ("\t", "\t"), 28: ("\n", "\n"),
}
for _code, _letter in [(16, "q"), (17, "w"), (18, "e"), (19, "r"), (20, "t"), (21, "y"),
(22, "u"), (23, "i"), (24, "o"), (25, "p"), (30, "a"), (31, "s"),
(32, "d"), (33, "f"), (34, "g"), (35, "h"), (36, "j"), (37, "k"),
(38, "l"), (44, "z"), (45, "x"), (46, "c"), (47, "v"), (48, "b"),
(49, "n"), (50, "m")]:
TYPED[_code] = (_letter, _letter.upper())
SHIFTS = {42, 54}
CONTROLS = {29, 97, 56, 100, 125, 126}
# Linux EV_LED indices are ZERO-based (LED_NUML 0, LED_CAPSL 1, LED_SCROLLL 2 ...),
# which is one less than the HID/USB LED bit positions the firmware reports -- the
# first version of this table was copied from the HID numbering and labelled every
# LED one along. Verified against real events: a CapsLock toggle reports code 1.
LED_NAMES = {0: "NumLock", 1: "CapsLock", 2: "ScrollLock", 3: "Compose",
4: "Kana", 5: "Sleep", 6: "Suspend"}
# Sequences worth calling out by name instead of as three separate key presses:
# these are the ones whose whole meaning is the combination.
CHORDS = {
frozenset({29, 56, 111}): "Ctrl+Alt+Del",
frozenset({29, 56, 61}): "Ctrl+Alt+F3 (VT switch)",
frozenset({29, 56, 59}): "Ctrl+Alt+F1 (VT switch)",
frozenset({29, 56, 60}): "Ctrl+Alt+F2 (VT switch)",
frozenset({29, 56, 62}): "Ctrl+Alt+F4 (VT switch)",
frozenset({29, 56, 70}): "Ctrl+Alt+ScrollLock (SysRq reboot path)",
frozenset({125, 56}): "Super+Alt",
}
def read_sysfs(path: str) -> str:
try:
with open(path) as handle:
return handle.read().strip()
except OSError:
return ""
def keyboard_nodes() -> list[str]:
"""evdev nodes of every USB device in IDENTITIES."""
nodes: list[str] = []
for device in glob.glob("/sys/bus/usb/devices/*"):
if (read_sysfs(f"{device}/idVendor"),
read_sysfs(f"{device}/idProduct")) not in IDENTITIES:
continue
# Depth is spelled out on purpose: a recursive ** glob over sysfs stalls.
found = glob.glob(f"{device}/*/input/input*/event*")
found += glob.glob(f"{device}/*/*/input/input*/event*")
nodes += ["/dev/input/" + n.rsplit("/", 1)[-1] for n in sorted(found)]
return sorted(set(nodes))
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--seconds", type=float, default=20.0)
ap.add_argument("--show-all", action="store_true",
help="also print key codes this tool cannot name (unknown codes "
"are counted, never dropped silently: they show in totals)")
args = ap.parse_args()
nodes = keyboard_nodes()
if not nodes:
ids = " or ".join(":".join(i) for i in IDENTITIES)
print(f"no keyboard for {ids} under /sys/bus/usb -- is the board plugged in "
f"and enumerated here?", file=sys.stderr)
return 1
if len(nodes) > 1:
print(f"note: several matching nodes {nodes}; watching the first")
path = nodes[0]
print(f"watching {path} for {args.seconds:.0f} s -- press keys on the controller")
print(f"{'t':>9} event")
held: set[int] = set()
typed: list[str] = []
presses = 0
chords_seen: set[str] = set()
reported_chords: set[str] = set()
shift = False
start: float | None = None
errors = 0
with open(path, "rb", buffering=0) as handle:
deadline = time.time() + args.seconds
while time.time() < deadline:
remaining = max(0.0, deadline - time.time())
if not select.select([handle], [], [], remaining)[0]:
continue
raw = handle.read(EVENT_SIZE)
if raw is None or len(raw) != EVENT_SIZE:
errors += 1
continue
sec, usec, event_type, code, value = struct.unpack(EVENT_FORMAT, raw)
if start is None:
start = sec + usec / 1e6
stamp = (sec + usec / 1e6) - start
if event_type == EV_LED:
name = LED_NAMES.get(code, f"LED{code}")
print(f" t={stamp:6.2f}s LED {name} {'on' if value else 'off'}")
continue
if event_type != EV_KEY:
continue
name = KEY_NAMES.get(code, f"key{code}")
if name.startswith("key") and not args.show_all:
continue
if value == 1: # press
presses += 1
held.add(code)
if code in SHIFTS:
shift = True
current = frozenset(held)
for combo, label in CHORDS.items():
if combo <= current and label not in reported_chords:
reported_chords.add(label)
chords_seen.add(label)
print(f" t={stamp:6.2f}s >>> {label} <<<")
if code in TYPED and not (current & CONTROLS):
typed.append(TYPED[code][1 if shift else 0])
elif code in TYPED and code == 28:
typed.append("<Enter-with-modifiers>")
state = "press"
elif value == 0: # release
held.discard(code)
if code in SHIFTS and not (held & SHIFTS):
shift = False
state = "release"
else: # autorepeat
state = "repeat"
print(f" t={stamp:6.2f}s {name:>16} {state}", flush=True)
print()
print(f" key presses {presses}")
if chords_seen:
print(f" chords {', '.join(sorted(chords_seen))}")
if typed:
text = "".join(typed)
preview = text if len(text) <= 240 else text[:240] + "..."
print(f" typed {preview!r}")
else:
print(" typed (nothing plain-text -- modifiers or chords only)")
if held:
print(f" still held {', '.join(KEY_NAMES.get(c, str(c)) for c in held)}"
" <-- a stuck key: check the controller released it")
if errors:
print(f" short reads {errors}")
return 0
if __name__ == "__main__":
sys.exit(main())