Files
keyrelay/tools/to_bootloader.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

98 lines
3.5 KiB
Python

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