Files
ducoterra ece0313d02 feat: relative mouse on the HID interface, and the verifier to prove it
The board now enumerates as a boot keyboard plus a relative mouse on the same
HID interface. The pointer is captured, never grabbed: Pointer Lock in the
page, so Esc (the browser's own, never forwarded) always frees it, and every
frame restates the whole button byte so a frame lost in the WiFi cannot leave
a button held. Losing the lock, the tab or the link runs the same release
funnel as a stuck key, and boot protocol or `mouse off` refuses mouse frames
with E11 rather than sending bytes a boot interface would misparse as keys.

src/mouse.h owns the report, src/output.h the queues and the funnel; the page,
relayctl.py and watch_target.py carry the button bits and the evdev decoding.
The verifier's own tables are pinned now — REL_WHEEL is 8, not 2, and the
first version guessed from the report's field order, so a working wheel looked
dead while the summary said no scroll happened.

LLM-Generated: true
2026-11-08 23:27:42 -05:00

102 lines
3.3 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:
# `reboot` makes the device disappear underneath us: the CDC node is gone the
# instant the board resets, so restoring the port settings can legitimately fail.
# The command already reached the board -- do not turn a successful `reboot` into
# a Python traceback and a non-zero exit.
try:
termios.tcsetattr(fd, termios.TCSAFLUSH, saved)
except (OSError, termios.error): # termios.error is NOT an OSError here
pass
try:
os.close(fd)
except OSError:
pass
if __name__ == "__main__":
sys.exit(main())