Fix flash utility compatibility and rebuild firmware binaries

This commit is contained in:
James L
2026-03-15 19:57:17 -04:00
parent 7e56611fe6
commit b3b6cd4302
4 changed files with 166 additions and 56 deletions

208
flash.py
View File

@@ -7,12 +7,19 @@ No PlatformIO required — just Python 3 and a USB cable.
By default, downloads the latest firmware from GitHub Releases (if newer than
the local cache) and flashes the app partition only, preserving bootloader,
partition table, NVS, and EEPROM settings.
partition table, NVS, and EEPROM settings. For reproducible flashing, the
script prefers the bundled esptool in Release/ over any host-installed copy.
Usage:
# Update firmware — V4 (default)
python flash.py
# Legacy alias for an app-only update flow
python flash.py --update
# Use a host-installed esptool instead of the bundled copy
python flash.py --use-system-esptool
# Update firmware — V3
python flash.py --board v3
@@ -55,6 +62,7 @@ GITHUB_REPO = "jrl290/RTNode-HeltecV4"
# Runtime state (set automatically during main())
_flash_mode_override = None # CLI --flash-mode sets this; otherwise board profile wins
_esptool_write_verify_support = {}
# Flash addresses for ESP32-S3 Arduino framework
BOOTLOADER_ADDR = 0x0000
@@ -316,27 +324,13 @@ def detect_board(port, esptool_cmd):
# ── Helpers ────────────────────────────────────────────────────────────────────
def find_esptool():
"""Find esptool — pip-installed, user-local, bundled, or PlatformIO's copy.
def find_esptool(prefer_system=False):
"""Find esptool, preferring repo-managed copies for reproducible flashing.
Prefer pip/pipx-installed esptool first (handles its own deps and is
usually the newest version), then fall back to the bundled script.
Default order is bundled Release/ copy, then PlatformIO's packaged copy,
then any host-installed esptool. Pass ``prefer_system=True`` to invert that
preference when a user explicitly wants their machine-wide installation.
"""
# 1. pip-installed esptool on PATH
if shutil.which("esptool.py"):
return ["esptool.py"]
if shutil.which("esptool"):
return ["esptool"]
# 2. Common user-local install locations (pip install --user)
for candidate in [
os.path.expanduser("~/.local/bin/esptool"),
os.path.expanduser("~/.local/bin/esptool.py"),
]:
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
print(f" Found user-local esptool: {candidate}")
return [candidate]
# Check if pyserial is available before using script-based esptool
try:
import serial # noqa: F401
@@ -344,20 +338,36 @@ def find_esptool():
except ImportError:
has_pyserial = False
# 2. Bundled in Release/
bundled = os.path.join(os.path.dirname(__file__), "Release", "esptool", "esptool.py")
if os.path.isfile(bundled) and has_pyserial:
return [sys.executable, bundled]
# 3. PlatformIO's esptool
pio_esptool = os.path.expanduser(
"~/.platformio/packages/tool-esptoolpy/esptool.py"
)
if os.path.isfile(pio_esptool) and has_pyserial:
return [sys.executable, pio_esptool]
# 4. Bundled exists but pyserial is missing — tell the user
if os.path.isfile(bundled) and not has_pyserial:
repo_candidates = []
if has_pyserial:
if os.path.isfile(bundled):
repo_candidates.append(([sys.executable, bundled], f"bundled esptool: {bundled}"))
if os.path.isfile(pio_esptool):
repo_candidates.append(([sys.executable, pio_esptool], f"PlatformIO esptool: {pio_esptool}"))
system_candidates = []
if shutil.which("esptool.py"):
system_candidates.append((["esptool.py"], "system esptool.py from PATH"))
if shutil.which("esptool"):
system_candidates.append((["esptool"], "system esptool from PATH"))
for candidate in [
os.path.expanduser("~/.local/bin/esptool"),
os.path.expanduser("~/.local/bin/esptool.py"),
]:
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
system_candidates.append(([candidate], f"user-local esptool: {candidate}"))
search_order = system_candidates + repo_candidates if prefer_system else repo_candidates + system_candidates
for command, source in search_order:
print(f" Found {source}")
return command
if (os.path.isfile(bundled) or os.path.isfile(pio_esptool)) and not has_pyserial:
print("Found bundled esptool but pyserial is not installed.")
print("Install it with: pip install pyserial")
print("Or install the standalone esptool: pip install esptool")
@@ -366,6 +376,33 @@ def find_esptool():
return None
def esptool_supports_write_verify(esptool_cmd):
"""Return True if this esptool build accepts ``write_flash --verify``.
esptool v5 removed ``--verify`` from write-flash, while older releases
still accept it. Probe once and cache the result so flashing can choose
the compatible verification path.
"""
cache_key = tuple(esptool_cmd)
if cache_key in _esptool_write_verify_support:
return _esptool_write_verify_support[cache_key]
try:
result = subprocess.run(
esptool_cmd + ["write_flash", "-h"],
capture_output=True,
text=True,
timeout=10,
)
output = (result.stdout or "") + (result.stderr or "")
supported = "--verify" in output
except Exception:
supported = False
_esptool_write_verify_support[cache_key] = supported
return supported
def find_serial_port():
"""List available serial ports and let the user choose."""
system = platform.system()
@@ -712,8 +749,8 @@ def check_partition_table(port, esptool_cmd, baud=None):
"""Compare the device's partition table against the expected one.
Returns:
True — partition table matches (or no expected table to compare against)
False — partition table mismatch (device needs full flash)
True — partition table matches (or no expected table to compare against)
False — partition table mismatch or unreadable state (device needs full flash)
"""
expected_path = find_partitions()
if not expected_path:
@@ -727,8 +764,7 @@ def check_partition_table(port, esptool_cmd, baud=None):
device_data = read_device_partitions(port, esptool_cmd, baud)
if device_data is None:
print(" Could not read partition table from device")
# Can't verify — assume OK (user can always use --full)
return True
return False
# Compare only the meaningful portion (both should be PARTITION_TABLE_SIZE)
if device_data[:len(expected)] == expected:
@@ -750,7 +786,7 @@ def check_app_on_device(port, esptool_cmd, baud=None):
Reads a small chunk from APP_ADDR (0x10000). If the region is all 0xFF
(erased flash), no app is present and the device needs a full flash.
Returns True if app firmware is detected, False if blank/absent.
Returns True if app firmware is detected, False if blank/absent or unreadable.
"""
import tempfile
if baud is None:
@@ -772,7 +808,7 @@ def check_app_on_device(port, esptool_cmd, baud=None):
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
print(" Warning: Could not read app region from device")
return True # assume app exists if we can't check
return False
with open(tmp.name, "rb") as f:
data = f.read()
# All 0xFF means flash is blank — no app present
@@ -781,7 +817,7 @@ def check_app_on_device(port, esptool_cmd, baud=None):
return True
except Exception as e:
print(f" Warning: App check failed: {e}")
return True # assume app exists if we can't check
return False
finally:
try:
os.unlink(tmp.name)
@@ -822,6 +858,37 @@ def reset_to_bootloader(port):
return True
def verify_firmware(firmware_path, port, esptool_cmd, baud=None,
flash_mode=None, no_hard_reset=False):
"""Verify flashed firmware using esptool's dedicated verify command."""
if baud is None:
baud = BAUD_RATE()
flash_size = FLASH_SIZE()
mode = flash_mode or BOARD_FLASH_MODE()
is_merged = is_merged_binary(firmware_path)
flash_addr = f"0x{BOOTLOADER_ADDR:x}" if is_merged else f"0x{APP_ADDR:x}"
after_arg = "no_reset" if no_hard_reset else "hard_reset"
print("\nVerifying flashed firmware...")
cmd = esptool_cmd + [
"--chip", CHIP,
"--port", port,
"--baud", baud,
"--before", "no_reset",
"--after", after_arg,
"verify_flash",
"--flash_mode", mode,
"--flash_freq", FLASH_FREQ,
"--flash_size", flash_size,
flash_addr, firmware_path,
]
print("Running: " + " ".join(cmd[-8:]))
result = subprocess.run(cmd)
return result.returncode == 0
def flash_firmware(firmware_path, port, esptool_cmd, baud=None,
no_reset_before=False, verify=False,
flash_mode=None, no_hard_reset=False):
@@ -851,8 +918,10 @@ def flash_firmware(firmware_path, port, esptool_cmd, baud=None,
flash_addr = f"0x{APP_ADDR:x}"
print(f" Detected: app-only binary -> flash at {flash_addr}")
inline_verify = verify and esptool_supports_write_verify(esptool_cmd)
post_write_verify = verify and not inline_verify
before_arg = "no_reset" if no_reset_before else "default_reset"
after_arg = "no_reset" if no_hard_reset else "hard_reset"
after_arg = "no_reset" if (no_hard_reset or post_write_verify) else "hard_reset"
cmd = esptool_cmd + [
"--chip", CHIP,
@@ -866,13 +935,26 @@ def flash_firmware(firmware_path, port, esptool_cmd, baud=None,
"--flash_freq", FLASH_FREQ,
"--flash_size", flash_size,
]
if verify:
if inline_verify:
cmd.append("--verify")
cmd += [flash_addr, firmware_path]
print("Running: " + " ".join(cmd[-8:]))
result = subprocess.run(cmd)
return result.returncode == 0
if result.returncode != 0:
return False
if post_write_verify:
return verify_firmware(
firmware_path,
port,
esptool_cmd,
baud=baud,
flash_mode=mode,
no_hard_reset=no_hard_reset,
)
return True
def _monitor_boot(port, timeout=8):
@@ -937,15 +1019,28 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python flash.py # Download latest & app-only update (V4)
python flash.py --board v3 # Download latest & app-only update (V3)
python flash.py --release v1.0.12 # Flash a specific release version
python flash.py --full # Full flash with merged binary
python flash.py --offline # Use cached/local firmware only
python flash.py --file firmware.bin # Flash a specific file
python flash.py --merge-only # Build merged binary for release
python flash.py --port /dev/ttyACM0 # Specify serial port
python flash.py --erase # Erase flash, then full flash (auto-verify)
python flash.py
Download latest firmware and flash default board.
python flash.py --update
Legacy alias for an app-only update.
python flash.py --use-system-esptool
Prefer a host-installed esptool over the bundled Release copy.
python flash.py --board v3
Download latest firmware and flash a V3 board.
python flash.py --release v1.0.12
Flash a specific release tag.
python flash.py --full
Do a full flash with the merged binary.
python flash.py --offline
Use only cached or local firmware.
python flash.py --file firmware.bin
Flash a specific local binary.
python flash.py --merge-only
Build the merged release binary without flashing.
python flash.py --port /dev/ttyACM0
Use a specific serial port.
python flash.py --erase
Erase flash first, then do a full flash.
""",
)
parser.add_argument("--board", choices=["v3", "v4"], default=None,
@@ -956,6 +1051,8 @@ Examples:
parser.add_argument("--baud", "-b", default=None, help="Baud rate (board-specific default)")
parser.add_argument("--release", "-r", default=None, metavar="TAG",
help="Flash a specific release version (e.g. v1.0.12)")
parser.add_argument("--update", action="store_true",
help="Legacy alias for an app-only firmware update")
parser.add_argument("--offline", action="store_true",
help="Skip online check — use cached or local firmware only")
parser.add_argument("--merge-only", action="store_true",
@@ -964,17 +1061,28 @@ Examples:
help="Flash merged binary (bootloader + partitions + app) — overwrites everything")
parser.add_argument("--erase", action="store_true",
help="Erase entire flash before writing (implies --full)")
parser.add_argument("--use-system-esptool", action="store_true",
help="Use a host-installed esptool instead of the bundled Release copy")
# Power-user override (not shown in --help)
parser.add_argument("--flash-mode", default=None,
help=argparse.SUPPRESS)
args = parser.parse_args()
if args.update and args.offline:
parser.error("--update cannot be combined with --offline")
if args.update:
print("Using legacy compatibility flag; default behavior already downloads and flashes the latest firmware unless --offline is set.")
# Find esptool early — needed for both auto-detect and flashing
esptool_cmd = find_esptool()
esptool_cmd = find_esptool(prefer_system=args.use_system_esptool)
if not esptool_cmd:
print("Error: esptool not found!")
print("Install it with: pip install esptool")
print("Expected one of:")
print(" 1. Bundled Release/esptool/esptool.py with pyserial available")
print(" 2. PlatformIO's packaged esptool")
print(" 3. A host-installed esptool (pip install esptool)")
sys.exit(1)
# ── Board detection ─────────────────────────────────────────────────