Support both Nano bootloaders for flashing over WiFi

- Uploader sends 'FLASH <baud>' first; the ESP32 restarts the Nano and
  bridges at that baud rate, then answers OK
- --baud=auto (default) tries 57600 (old ATmegaBOOT) then 115200 (Optiboot)
- Optional nano_reset GPIO (NPN to Nano RESET) pulses reset after power
  on, needed for Optiboot which skips the bootloader after a power cycle
- Clear errors for outdated ESP32 firmware and missing wires
- README: bootloader table, reset wire, troubleshooting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-13 00:44:35 +02:00
1 parent bea5b9daa1
commit cefb76c1ad
8 files changed
+220 -77

No files matched your search

+103 -50
View File
@@ -1,25 +1,37 @@
#!/usr/bin/env python3
"""Flash the Arduino Nano over WiFi, through the ESP32's flash bridge.
Usage: python nano_net_upload.py firmware.hex HOST[:PORT]
Usage: python nano_net_upload.py firmware.hex HOST[:PORT] [--baud auto|57600|115200]
The ESP32 power cycles the Nano when we connect (so its bootloader starts) and then
passes bytes between this TCP connection and the Nano's serial port. This script talks
the STK500v1 protocol of the Arduino bootloader directly. avrdude can't be used here:
its network ports are unreliable on Windows.
--baud is the Nano bootloader's baud rate:
57600 old Nano bootloader (ATmegaBOOT)
115200 Optiboot ("new bootloader"); needs the ESP32 reset wire
auto try 57600 first, then 115200 (default)
The ESP32 restarts the Nano when we connect (so its bootloader starts) and then passes
bytes between this TCP connection and the Nano's serial port. This script talks the
STK500v1 protocol of the Arduino bootloaders directly. avrdude can't be used here: its
network ports are unreliable on Windows.
Normally run by PlatformIO: pio run -e nano_ota -t upload
"""
import argparse
import socket
import sys
import time
DEFAULT_PORT = 2323
PAGE_SIZE = 128 # ATmega328P flash page in bytes
FLASH_SIZE = 30720 # 32 KB minus the 2 KB bootloader
SIGNATURE = b"\x1e\x95\x0f" # ATmega328P
# Bootloaders: baud rate -> (name, flash space left for the firmware)
BOOTLOADERS = {
57600: ("old bootloader", 32768 - 2048),
115200: ("Optiboot", 32768 - 512),
}
AUTO_ORDER = [57600, 115200]
# STK500v1
STK_OK = 0x10
STK_INSYNC = 0x14
@@ -32,7 +44,8 @@ PROG_PAGE = 0x64
READ_PAGE = 0x74
READ_SIGN = 0x75
SYNC_TIMEOUT = 10.0 # seconds to wait for the bootloader after connecting
BRIDGE_TIMEOUT = 5.0 # wait for the ESP32's OK (it power cycles the Nano first)
SYNC_TIMEOUT = 4.0 # wait for the bootloader to answer after the restart
REPLY_TIMEOUT = 1.0
@@ -40,8 +53,12 @@ class UploadError(Exception):
pass
class NoBootloader(UploadError):
"""The bootloader didn't answer (wrong baud rate, missing wire, ...)."""
def read_hex(path):
"""Parse an Intel HEX file into a bytearray (gaps filled with 0xFF)."""
"""Parse an Intel HEX file into a bytearray (gaps filled with 0xFF), padded to whole pages."""
image = bytearray()
base = 0
with open(path) as f:
@@ -68,9 +85,6 @@ def read_hex(path):
base = ((data[0] << 8) | data[1]) << 4
elif rtype == 0x04: # extended linear address
base = ((data[0] << 8) | data[1]) << 16
if len(image) > FLASH_SIZE:
raise UploadError(f"firmware is {len(image)} bytes, the Nano has room for {FLASH_SIZE}")
# pad to whole pages
if len(image) % PAGE_SIZE:
image.extend(b"\xff" * (PAGE_SIZE - len(image) % PAGE_SIZE))
return image
@@ -81,7 +95,6 @@ class Bootloader:
self.sock = sock
def _recv(self, n, timeout=REPLY_TIMEOUT):
self.sock.settimeout(timeout)
data = b""
deadline = time.monotonic() + timeout
while len(data) < n:
@@ -112,7 +125,7 @@ class Bootloader:
return reply[1:-1]
def sync(self):
"""Keep sending GET_SYNC until the bootloader answers (it starts after the power cycle)."""
"""Keep sending GET_SYNC until the bootloader answers (it starts after the restart)."""
deadline = time.monotonic() + SYNC_TIMEOUT
while time.monotonic() < deadline:
self.sock.sendall(bytes([GET_SYNC, CRC_EOP]))
@@ -122,9 +135,7 @@ class Bootloader:
self._drain()
self.command([GET_SYNC])
return
raise UploadError(
f"no answer from the Nano bootloader within {SYNC_TIMEOUT:.0f} s. "
"Is the Nano TX -> ESP32 GPIO 16 wire connected?")
raise NoBootloader(f"no answer from the Nano bootloader within {SYNC_TIMEOUT:.0f} s")
def load_address(self, byte_addr):
word = byte_addr // 2
@@ -147,56 +158,98 @@ def progress(label, done, total, step=PAGE_SIZE):
print(f"{label} {percent:3d}% ({done}/{total} bytes)", flush=True)
def upload(hex_path, host, port):
image = read_hex(hex_path)
print(f"Firmware: {hex_path} ({len(image)} bytes)")
print(f"Connecting to the ESP32 flash bridge at {host}:{port} ...")
def connect(host, port, baud):
"""Connect to the ESP32 bridge and have it restart the Nano into its bootloader."""
try:
sock = socket.create_connection((host, port), timeout=10)
except OSError as e:
raise UploadError(f"can't connect to {host}:{port}: {e}")
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
with sock:
boot = Bootloader(sock)
print("Waiting for the Nano bootloader (the ESP32 power cycles the display) ...")
boot.sync()
sock.sendall(f"FLASH {baud}\n".encode())
reply = b""
deadline = time.monotonic() + BRIDGE_TIMEOUT
while not reply.endswith(b"\n") and time.monotonic() < deadline:
sock.settimeout(max(deadline - time.monotonic(), 0.01))
try:
chunk = sock.recv(64)
except socket.timeout:
break
if not chunk:
break
reply += chunk
if reply != b"OK\n":
sock.close()
raise UploadError(
f"the ESP32 flash bridge didn't accept the request (reply: {reply!r}). "
"Is the ESP32 running up-to-date firmware?")
return sock
signature = boot.command([READ_SIGN], reply_len=3)
if signature != SIGNATURE:
raise UploadError(f"unexpected device signature {signature.hex()} (expected ATmega328P)")
print("Found ATmega328P")
boot.command([ENTER_PROGMODE])
def flash(sock, image, baud):
name, flash_size = BOOTLOADERS[baud]
if len(image) > flash_size:
raise UploadError(f"firmware is {len(image)} bytes, but with the {name} the Nano has room for {flash_size}")
for addr in range(0, len(image), PAGE_SIZE):
boot.write_page(addr, image[addr:addr + PAGE_SIZE])
progress("Writing ", addr + PAGE_SIZE, len(image))
boot = Bootloader(sock)
signature = boot.command([READ_SIGN], reply_len=3)
if signature != SIGNATURE:
raise UploadError(f"unexpected device signature {signature.hex()} (expected ATmega328P)")
print(f"Found ATmega328P, bootloader: {name} ({baud} baud)")
for addr in range(0, len(image), PAGE_SIZE):
expected = image[addr:addr + PAGE_SIZE]
actual = boot.read_page(addr, PAGE_SIZE)
if actual != expected:
for i, (a, b) in enumerate(zip(actual, expected)):
if a != b:
raise UploadError(
f"verify failed at 0x{addr + i:04x}: read 0x{a:02x}, expected 0x{b:02x}")
progress("Verifying", addr + PAGE_SIZE, len(image))
boot.command([ENTER_PROGMODE])
boot.command([LEAVE_PROGMODE])
for addr in range(0, len(image), PAGE_SIZE):
boot.write_page(addr, image[addr:addr + PAGE_SIZE])
progress("Writing ", addr + PAGE_SIZE, len(image))
print("Done. The ESP32 restarts the Nano with the new firmware.")
for addr in range(0, len(image), PAGE_SIZE):
expected = image[addr:addr + PAGE_SIZE]
actual = boot.read_page(addr, PAGE_SIZE)
if actual != expected:
for i, (a, b) in enumerate(zip(actual, expected)):
if a != b:
raise UploadError(f"verify failed at 0x{addr + i:04x}: read 0x{a:02x}, expected 0x{b:02x}")
progress("Verifying", addr + PAGE_SIZE, len(image))
boot.command([LEAVE_PROGMODE])
def upload(hex_path, host, port, bauds):
image = read_hex(hex_path)
print(f"Firmware: {hex_path} ({len(image)} bytes)")
for i, baud in enumerate(bauds):
print(f"Connecting to the ESP32 flash bridge at {host}:{port}, "
f"restarting the Nano into its bootloader at {baud} baud ...", flush=True)
with connect(host, port, baud) as sock:
try:
Bootloader(sock).sync()
except NoBootloader:
if i + 1 < len(bauds):
print(f"No answer at {baud} baud, trying {bauds[i + 1]} ...", flush=True)
continue
raise NoBootloader(
f"no answer from the Nano bootloader at {', '.join(map(str, bauds))} baud. "
"Check the Nano TX -> ESP32 GPIO 16 wire. A Nano with Optiboot also needs "
"the reset wire (nano_reset in src/esp32/libs.h).")
flash(sock, image, baud)
print("Done. The ESP32 restarts the Nano with the new firmware.")
return
def main():
if len(sys.argv) != 3:
print(__doc__)
return 2
hex_path, target = sys.argv[1], sys.argv[2]
host, _, port = target.partition(":")
parser = argparse.ArgumentParser(description="Flash the Nano over WiFi through the ESP32.")
parser.add_argument("hex", help="firmware .hex file")
parser.add_argument("target", help=f"ESP32 address, HOST[:PORT] (default port {DEFAULT_PORT})")
parser.add_argument("--baud", default="auto", choices=["auto"] + [str(b) for b in BOOTLOADERS],
help="bootloader baud rate (default: auto)")
args = parser.parse_args()
host, _, port = args.target.partition(":")
bauds = AUTO_ORDER if args.baud == "auto" else [int(args.baud)]
try:
upload(hex_path, host, int(port) if port else DEFAULT_PORT)
upload(args.hex, host, int(port) if port else DEFAULT_PORT, bauds)
except UploadError as e:
sys.stdout.flush()
print(f"ERROR: {e}", file=sys.stderr)