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
92 lines
2.8 KiB
Python
92 lines
2.8 KiB
Python
#!/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())
|