Files
2026-07-17 15:29:53 -04:00

180 lines
5.9 KiB
Python

"""Auto-detect a running ATOMS3 MacroPad and trigger ROM download mode.
Used by upload.bat to skip the manual "hold the side button while plugging
in" dance when a connected device is already running our firmware (which
exposes the `bootloader` serial command). On success, writes the detected
download-mode port to the file given as argv[1] and exits 0. On any
failure (no device, ping miss, bootloader command rejected, device fails
to re-enumerate) exits non-zero so the .bat falls back to manual mode.
This only works for devices already running our firmware — stock ATOMS3
firmware does not support the `bootloader` command, which is why
upload.bat keeps the manual flow as a fallback.
"""
import json
import sys
import time
import serial
import serial.tools.list_ports
DEVICE_ID = "ATOMS3-MACROPAD"
ESPRESSIF_VID = 0x303A
PING_TIMEOUT_S = 2.0
REENUMERATE_TIMEOUT_S = 8.0
def _espressif_ports() -> list:
return [p for p in serial.tools.list_ports.comports() if p.vid == ESPRESSIF_VID]
def _ping(port: str) -> dict | None:
"""Open the port without resetting, send a ping, return parsed reply or None."""
try:
ser = serial.Serial()
ser.port = port
ser.baudrate = 115200
ser.timeout = PING_TIMEOUT_S
ser.dtr = False
ser.rts = False
ser.open()
except (serial.SerialException, OSError):
return None
try:
time.sleep(0.1)
ser.dtr = True
time.sleep(0.3)
ser.reset_input_buffer()
ser.write(b'{"cmd":"ping"}\n')
line = ser.readline().decode("utf-8", errors="ignore").strip()
if not line:
return None
return json.loads(line)
except (serial.SerialException, json.JSONDecodeError, OSError):
return None
finally:
try:
ser.close()
except Exception:
pass
def _find_macropad() -> str | None:
for p in _espressif_ports():
reply = _ping(p.device)
if reply and reply.get("id") == DEVICE_ID:
print(f" Found {DEVICE_ID} v{reply.get('ver', '?')} on {p.device}",
file=sys.stderr)
return p.device
return None
def _send_bootloader(port: str) -> bool:
"""Tell the firmware to call usb_persist_restart(RESTART_BOOTLOADER).
The device acks with `{"rsp":"ok"}` then disappears off this port and
re-enumerates as USB-Serial-JTAG (the native ESP32-S3 bootloader USB
interface). We don't wait for the ack here beyond the read timeout —
even a successful command vanishes the port within ~100 ms.
"""
try:
ser = serial.Serial()
ser.port = port
ser.baudrate = 115200
ser.timeout = 1.0
ser.dtr = False
ser.rts = False
ser.open()
except (serial.SerialException, OSError) as exc:
print(f" ERROR: could not open {port}: {exc}", file=sys.stderr)
return False
try:
time.sleep(0.1)
ser.dtr = True
time.sleep(0.3)
ser.reset_input_buffer()
ser.write(b'{"cmd":"bootloader"}\n')
ser.flush()
# Best-effort read of the ack; absence of one is fine — the chip
# is already on its way to the ROM bootloader.
try:
line = ser.readline().decode("utf-8", errors="ignore").strip()
print(f" bootloader cmd reply: {line or '(none)'}", file=sys.stderr)
except Exception:
pass
return True
except (serial.SerialException, OSError) as exc:
print(f" ERROR sending bootloader command: {exc}", file=sys.stderr)
return False
finally:
try:
ser.close()
except Exception:
pass
def _wait_for_download_port(prev_macropad_port: str) -> str | None:
"""Wait for an Espressif device to appear in download mode.
Strategy: poll the COM enumeration. Accept the first Espressif port we
can see that either (a) is a *different* port than the one the firmware
was on, or (b) is the same port but `ping` no longer answers (firmware
is gone, USB-Serial-JTAG is up). The same-port case is common on
Windows because the OS often reuses the COM number across the PHY
switch.
"""
deadline = time.monotonic() + REENUMERATE_TIMEOUT_S
# Give the device a moment to vanish before we start polling.
time.sleep(0.5)
while time.monotonic() < deadline:
ports = [p.device for p in _espressif_ports()]
for port in ports:
if port != prev_macropad_port:
return port
# Same port re-appeared (or never disappeared): confirm firmware is
# no longer responding. If ping fails, this is the bootloader.
if prev_macropad_port in ports:
if _ping(prev_macropad_port) is None:
return prev_macropad_port
time.sleep(0.4)
return None
def main(argv: list) -> int:
if len(argv) < 2:
print("usage: auto_enter_bootloader.py <output_port_file>", file=sys.stderr)
return 2
out_path = argv[1]
print("Scanning for a running ATOMS3 MacroPad...", file=sys.stderr)
fw_port = _find_macropad()
if not fw_port:
print(" No running MacroPad detected on any Espressif COM port.",
file=sys.stderr)
return 1
print(f"Asking {fw_port} to enter download mode...", file=sys.stderr)
if not _send_bootloader(fw_port):
return 1
print("Waiting for the device to re-enumerate as USB-Serial-JTAG...",
file=sys.stderr)
dl_port = _wait_for_download_port(fw_port)
if not dl_port:
print(" Timed out waiting for download-mode port.", file=sys.stderr)
return 1
print(f" Download-mode port: {dl_port}", file=sys.stderr)
try:
with open(out_path, "w", encoding="ascii") as f:
f.write(dl_port)
except OSError as exc:
print(f" ERROR writing {out_path}: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))