Public Access
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:
1 parent
bea5b9daa1
commit
cefb76c1ad
8 files changed
+220
-77
No files matched your search
+103
-50
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user