Files
keyrelay/tools/relayctl.py
T
ducoterra 9bc8d5213c 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
2026-11-08 00:20:33 -05:00

704 lines
31 KiB
Python

#!/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())