Initial public release
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user