Initial public release

This commit is contained in:
2026-07-17 15:29:53 -04:00
commit 2d71ce77a1
81 changed files with 32056 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
"""
wipe_device.py — Wipe ATOMS3 MacroPad NVS and LittleFS partitions.
Usage:
python wipe_device.py # auto-detect COM port
python wipe_device.py COM3 # use specified COM port
The script will:
1. Find the connected MacroPad automatically
2. Send {"cmd":"bootloader"} to trigger ROM download mode
3. Wait for the device to re-enumerate as USB-JTAG
4. Erase NVS (0x009000, 20 KB) — clears settings
5. Erase LittleFS (0x670000, 1.5 MB) — clears all macros
6. Reset the device back into normal firmware
"""
import glob
import json
import os
import subprocess
import sys
import time
import serial
import serial.tools.list_ports
# Force UTF-8 output on Windows terminals
if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
ESPRESSIF_VID = 0x303A
DEVICE_ID = "ATOMS3-MACROPAD"
BAUD_RATE = 115200
# Partition layout (matches default_8MB.csv flashed with firmware)
NVS_OFFSET = 0x009000
NVS_SIZE = 0x005000 # 20 KB
LITTLEFS_OFFSET = 0x670000
LITTLEFS_SIZE = 0x180000 # 1.5 MB
def find_esptool() -> str:
"""Locate esptool.exe inside the Arduino15 package cache."""
pattern = os.path.join(
os.environ.get("LOCALAPPDATA", ""),
"Arduino15", "packages", "*", "tools", "esptool_py", "*", "esptool.exe",
)
matches = sorted(glob.glob(pattern), reverse=True) # newest version first
if matches:
return matches[0]
raise FileNotFoundError(
"esptool.exe not found in Arduino15 package cache.\n"
"Make sure the M5Stack board package is installed via arduino-cli."
)
def list_espressif_ports() -> set[str]:
"""Return the set of COM port names currently belonging to Espressif devices."""
return {
p.device
for p in serial.tools.list_ports.comports()
if p.vid == ESPRESSIF_VID
}
def find_macropad_port() -> str | None:
"""Scan Espressif COM ports and return the one that responds as the MacroPad."""
for p in serial.tools.list_ports.comports():
if p.vid != ESPRESSIF_VID:
continue
try:
ser = serial.Serial()
ser.port = p.device
ser.baudrate = BAUD_RATE
ser.timeout = 2
ser.dtr = False
ser.rts = False
ser.open()
time.sleep(0.1)
ser.dtr = True # signal host-connected to TinyUSB CDC
time.sleep(0.3)
ser.reset_input_buffer()
ser.write(b'{"cmd":"ping"}\n')
line = ser.readline().decode("utf-8", errors="ignore").strip()
ser.close()
if line:
data = json.loads(line)
if data.get("id") == DEVICE_ID:
return p.device
except (serial.SerialException, json.JSONDecodeError, OSError):
pass
return None
def send_bootloader_command(port: str) -> None:
"""Open the port and fire the bootloader command. Ignores read errors on disconnect."""
ser = serial.Serial()
ser.port = port
ser.baudrate = BAUD_RATE
ser.timeout = 2
ser.dtr = False
ser.rts = False
ser.open()
time.sleep(0.1)
ser.dtr = True
time.sleep(0.3)
ser.reset_input_buffer()
ser.write(b'{"cmd":"bootloader"}\n')
ser.flush()
try:
# Read {"rsp":"ok"} — may throw if device resets mid-read
ser.read(64)
except (serial.SerialException, OSError):
pass
finally:
try:
ser.close()
except Exception:
pass
def wait_for_port_change(old_ports: set[str], timeout: float = 10.0) -> str:
"""
Block until a new Espressif COM port appears that wasn't in old_ports.
Returns the new port name, or raises TimeoutError.
"""
deadline = time.time() + timeout
while time.time() < deadline:
current = list_espressif_ports()
new = current - old_ports
if new:
return new.pop()
time.sleep(0.25)
raise TimeoutError(
"Timed out waiting for device to re-enumerate in download mode.\n"
"Check USB connection and try again."
)
def erase_region(esptool: str, port: str, offset: int, size: int, keep_stub: bool) -> None:
"""Run esptool to erase one flash region.
keep_stub=True leaves the stub running so the next call can reconnect
without a full reset cycle.
"""
after = "no-reset" if keep_stub else "watchdog-reset"
cmd = [
esptool,
"--chip", "esp32s3",
"--port", port,
"--baud", "460800",
"--before", "no-reset",
"--after", after,
"erase-region",
hex(offset),
hex(size),
]
result = subprocess.run(cmd, capture_output=True, text=True)
output = result.stdout + result.stderr
if result.returncode != 0:
print(output)
raise RuntimeError(f"esptool failed (exit {result.returncode})")
def main() -> None:
print("=" * 40)
print(" ATOMS3 MacroPad -- Device Wiper")
print("=" * 40 + "\n")
print("[ 1/5 ] Locating esptool...", end=" ", flush=True)
esptool = find_esptool()
print(f"OK\n {esptool}")
forced_port = sys.argv[1].upper() if len(sys.argv) > 1 else None
if forced_port:
print(f"[ 2/5 ] Using specified port {forced_port}...", end=" ", flush=True)
port = forced_port
print("OK")
else:
print("[ 2/5 ] Searching for MacroPad...", end=" ", flush=True)
port = find_macropad_port()
if not port:
print("NOT FOUND")
print("\nNo MacroPad detected. Make sure the device is connected and firmware is running.")
sys.exit(1)
print(f"found on {port}")
print("[ 3/5 ] Entering download mode...", end=" ", flush=True)
ports_before = list_espressif_ports()
send_bootloader_command(port)
# Wait for old port to vanish, then for the JTAG port to appear
time.sleep(0.5)
try:
jtag_port = wait_for_port_change(ports_before - {port}, timeout=10.0)
except TimeoutError as e:
print("TIMEOUT")
print(f"\n{e}")
sys.exit(1)
print(f"device on {jtag_port}")
# Let the ROM fully settle
time.sleep(0.5)
print(f"[ 4/5 ] Erasing NVS "
f"(0x{NVS_OFFSET:06X} 0x{NVS_OFFSET + NVS_SIZE - 1:06X}, "
f"{NVS_SIZE // 1024} KB)...", end=" ", flush=True)
try:
erase_region(esptool, jtag_port, NVS_OFFSET, NVS_SIZE, keep_stub=True)
except RuntimeError as e:
print("FAILED")
print(f"\n{e}")
sys.exit(1)
print("OK")
# Final erase resets the device
print(f"[ 5/5 ] Erasing LittleFS "
f"(0x{LITTLEFS_OFFSET:06X} 0x{LITTLEFS_OFFSET + LITTLEFS_SIZE - 1:06X}, "
f"{LITTLEFS_SIZE // 1024} KB)...", end=" ", flush=True)
try:
erase_region(esptool, jtag_port, LITTLEFS_OFFSET, LITTLEFS_SIZE, keep_stub=False)
except RuntimeError as e:
print("FAILED")
print(f"\n{e}")
sys.exit(1)
print("OK")
print("\nDone. NVS and LittleFS are blank -- device is in a fresh state.\n")
if __name__ == "__main__":
main()