flash.py: Auto-download from GitHub with cache, add --release/--offline flags
Default behavior now checks GitHub for the latest release and downloads
firmware if the cache is empty or outdated. Cached binaries are stored
in .firmware_cache/{board}/ with SHA-256 integrity verification.
New flags:
--release TAG Flash a specific release version
--offline Skip online check, use cached/local firmware only
Removed --download flag (downloading is now the default).
Added .firmware_cache/ and rnodethv3_firmware.bin to .gitignore.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,6 +3,8 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
TODO
|
TODO
|
||||||
rnodethv4_firmware.bin
|
rnodethv4_firmware.bin
|
||||||
|
rnodethv3_firmware.bin
|
||||||
|
.firmware_cache/
|
||||||
Release/*.hex
|
Release/*.hex
|
||||||
Release/*.zip
|
Release/*.zip
|
||||||
Release/*.json
|
Release/*.json
|
||||||
|
|||||||
262
flash.py
262
flash.py
@@ -5,8 +5,9 @@ RNodeTHV4 Flash Utility
|
|||||||
Flash the RNodeTHV4 boundary node firmware to a Heltec WiFi LoRa 32 V3 or V4.
|
Flash the RNodeTHV4 boundary node firmware to a Heltec WiFi LoRa 32 V3 or V4.
|
||||||
No PlatformIO required — just Python 3 and a USB cable.
|
No PlatformIO required — just Python 3 and a USB cable.
|
||||||
|
|
||||||
Default mode flashes only the app partition (0x10000), preserving
|
By default, downloads the latest firmware from GitHub Releases (if newer than
|
||||||
bootloader, partition table, NVS, and EEPROM settings.
|
the local cache) and flashes the app partition only, preserving bootloader,
|
||||||
|
partition table, NVS, and EEPROM settings.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
# Update firmware — V4 (default)
|
# Update firmware — V4 (default)
|
||||||
@@ -15,18 +16,21 @@ Usage:
|
|||||||
# Update firmware — V3
|
# Update firmware — V3
|
||||||
python flash.py --board v3
|
python flash.py --board v3
|
||||||
|
|
||||||
|
# Flash a specific release version
|
||||||
|
python flash.py --release v1.0.12
|
||||||
|
|
||||||
# Full flash with merged binary (overwrites everything)
|
# Full flash with merged binary (overwrites everything)
|
||||||
python flash.py --full
|
python flash.py --full
|
||||||
|
|
||||||
# Flash a specific file (auto-detects merged vs app-only)
|
# Flash a specific file (auto-detects merged vs app-only)
|
||||||
python flash.py --file firmware.bin
|
python flash.py --file firmware.bin
|
||||||
|
|
||||||
# Download latest from GitHub and flash
|
|
||||||
python flash.py --download
|
|
||||||
|
|
||||||
# Specify serial port manually
|
# Specify serial port manually
|
||||||
python flash.py --port /dev/ttyACM0
|
python flash.py --port /dev/ttyACM0
|
||||||
|
|
||||||
|
# Skip online check — use cached/local firmware only
|
||||||
|
python flash.py --offline
|
||||||
|
|
||||||
# Just build the merged binary (for GitHub Releases)
|
# Just build the merged binary (for GitHub Releases)
|
||||||
python flash.py --merge-only
|
python flash.py --merge-only
|
||||||
"""
|
"""
|
||||||
@@ -43,6 +47,7 @@ import time
|
|||||||
|
|
||||||
# ── Configuration ──────────────────────────────────────────────────────────────
|
# ── Configuration ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
VERSION = "1.0.13"
|
||||||
CHIP = "esp32s3"
|
CHIP = "esp32s3"
|
||||||
FLASH_MODE = "qio"
|
FLASH_MODE = "qio"
|
||||||
FLASH_FREQ = "80m"
|
FLASH_FREQ = "80m"
|
||||||
@@ -358,47 +363,156 @@ def sha256_file(path):
|
|||||||
return h.hexdigest()
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def download_firmware(dest_path):
|
def _cache_dir():
|
||||||
"""Download the latest merged firmware from GitHub Releases."""
|
"""Return the firmware cache directory (next to flash.py)."""
|
||||||
|
return os.path.join(os.path.dirname(os.path.abspath(__file__)), ".firmware_cache")
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_meta_path(board_key):
|
||||||
|
"""Return path to the cache metadata JSON for a given board."""
|
||||||
|
return os.path.join(_cache_dir(), board_key, "meta.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _cached_firmware_path(board_key):
|
||||||
|
"""Return path to the cached firmware binary for a given board."""
|
||||||
|
return os.path.join(_cache_dir(), board_key, BOARD_PROFILES[board_key]["merged_filename"])
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cache_meta(board_key):
|
||||||
|
"""Read cache metadata, returning dict or None if not cached."""
|
||||||
|
import json
|
||||||
|
meta_path = _cache_meta_path(board_key)
|
||||||
|
if os.path.isfile(meta_path):
|
||||||
|
try:
|
||||||
|
with open(meta_path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_cache_meta(board_key, tag, sha256):
|
||||||
|
"""Write cache metadata after a successful download."""
|
||||||
|
import json
|
||||||
|
cache = os.path.join(_cache_dir(), board_key)
|
||||||
|
os.makedirs(cache, exist_ok=True)
|
||||||
|
meta = {"tag": tag, "sha256": sha256}
|
||||||
|
with open(_cache_meta_path(board_key), "w") as f:
|
||||||
|
json.dump(meta, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_version_tag(tag):
|
||||||
|
"""Parse a version tag like 'v1.0.13' into a tuple (1, 0, 13) for comparison.
|
||||||
|
Returns None if the tag doesn't match the expected format."""
|
||||||
|
import re
|
||||||
|
m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", tag)
|
||||||
|
if m:
|
||||||
|
return tuple(int(x) for x in m.groups())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_release_info(tag=None):
|
||||||
|
"""Fetch release info from GitHub. If tag is None, fetches latest."""
|
||||||
try:
|
try:
|
||||||
from urllib.request import urlretrieve, urlopen
|
from urllib.request import urlopen, Request
|
||||||
import json
|
import json
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print("Error: Python urllib not available.")
|
return None, "Python urllib not available"
|
||||||
return False
|
|
||||||
|
|
||||||
api_url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
if tag:
|
||||||
print(f"Checking latest release from {GITHUB_REPO}...")
|
# Normalize: ensure tag starts with 'v'
|
||||||
|
if not tag.startswith("v"):
|
||||||
|
tag = f"v{tag}"
|
||||||
|
api_url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/tags/{tag}"
|
||||||
|
else:
|
||||||
|
api_url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with urlopen(api_url) as resp:
|
req = Request(api_url, headers={"Accept": "application/vnd.github+json"})
|
||||||
release = json.loads(resp.read())
|
with urlopen(req, timeout=10) as resp:
|
||||||
|
return json.loads(resp.read()), None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error fetching release info: {e}")
|
return None, str(e)
|
||||||
return False
|
|
||||||
|
|
||||||
# Find the merged firmware asset
|
|
||||||
|
def fetch_firmware(board_key, release_tag=None):
|
||||||
|
"""Fetch firmware from GitHub, using cache when possible.
|
||||||
|
|
||||||
|
Logic:
|
||||||
|
1. Query GitHub for the target release (latest or specific tag).
|
||||||
|
2. If the cached firmware matches that release tag, skip download.
|
||||||
|
3. Otherwise download the merged firmware binary and update cache.
|
||||||
|
|
||||||
|
Returns (firmware_path, release_tag) on success, (None, reason) on failure.
|
||||||
|
"""
|
||||||
|
from urllib.request import urlretrieve
|
||||||
|
|
||||||
|
merged_name = BOARD_PROFILES[board_key]["merged_filename"]
|
||||||
|
cache_path = _cached_firmware_path(board_key)
|
||||||
|
cache_meta = _read_cache_meta(board_key)
|
||||||
|
|
||||||
|
# 1. Fetch release info
|
||||||
|
label = f"release {release_tag}" if release_tag else "latest release"
|
||||||
|
print(f"Checking {label} from {GITHUB_REPO}...")
|
||||||
|
release, err = _fetch_release_info(release_tag)
|
||||||
|
if not release:
|
||||||
|
print(f" Could not reach GitHub: {err}")
|
||||||
|
# Fall back to cache if available
|
||||||
|
if cache_meta and os.path.isfile(cache_path):
|
||||||
|
print(f" Using cached firmware: {cache_meta['tag']}")
|
||||||
|
return cache_path, cache_meta["tag"]
|
||||||
|
return None, f"No cached firmware and GitHub unreachable: {err}"
|
||||||
|
|
||||||
|
remote_tag = release.get("tag_name", "unknown")
|
||||||
|
|
||||||
|
# 2. Check cache
|
||||||
|
if cache_meta and os.path.isfile(cache_path):
|
||||||
|
cached_tag = cache_meta.get("tag")
|
||||||
|
if cached_tag == remote_tag:
|
||||||
|
# Verify file integrity
|
||||||
|
actual_sha = sha256_file(cache_path)
|
||||||
|
if actual_sha == cache_meta.get("sha256"):
|
||||||
|
print(f" Cached firmware is up-to-date: {remote_tag}")
|
||||||
|
return cache_path, remote_tag
|
||||||
|
else:
|
||||||
|
print(f" Cache integrity mismatch — re-downloading")
|
||||||
|
else:
|
||||||
|
cached_ver = _parse_version_tag(cached_tag) if cached_tag else None
|
||||||
|
remote_ver = _parse_version_tag(remote_tag)
|
||||||
|
if cached_ver and remote_ver and remote_ver > cached_ver:
|
||||||
|
print(f" Newer version available: {cached_tag} → {remote_tag}")
|
||||||
|
elif cached_ver and remote_ver and remote_ver < cached_ver:
|
||||||
|
print(f" Requested version {remote_tag} is older than cached {cached_tag}")
|
||||||
|
else:
|
||||||
|
print(f" Version changed: {cached_tag} → {remote_tag}")
|
||||||
|
|
||||||
|
# 3. Find the asset URL
|
||||||
asset_url = None
|
asset_url = None
|
||||||
for asset in release.get("assets", []):
|
for asset in release.get("assets", []):
|
||||||
if asset["name"] == MERGED_FILENAME():
|
if asset["name"] == merged_name:
|
||||||
asset_url = asset["browser_download_url"]
|
asset_url = asset["browser_download_url"]
|
||||||
break
|
break
|
||||||
|
|
||||||
if not asset_url:
|
if not asset_url:
|
||||||
print(f"Error: '{MERGED_FILENAME()}' not found in latest release ({release.get('tag_name', '?')}).")
|
available = [a["name"] for a in release.get("assets", [])]
|
||||||
print("Available assets:", [a["name"] for a in release.get("assets", [])])
|
return None, (
|
||||||
return False
|
f"'{merged_name}' not found in release {remote_tag}.\n"
|
||||||
|
f" Available assets: {available}"
|
||||||
|
)
|
||||||
|
|
||||||
print(f"Downloading {release['tag_name']} / {MERGED_FILENAME()}...")
|
# 4. Download
|
||||||
|
os.makedirs(os.path.join(_cache_dir(), board_key), exist_ok=True)
|
||||||
|
print(f" Downloading {remote_tag} / {merged_name}...")
|
||||||
try:
|
try:
|
||||||
urlretrieve(asset_url, dest_path)
|
urlretrieve(asset_url, cache_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Download failed: {e}")
|
return None, f"Download failed: {e}"
|
||||||
return False
|
|
||||||
|
|
||||||
size = os.path.getsize(dest_path)
|
file_sha = sha256_file(cache_path)
|
||||||
print(f"Downloaded {size:,} bytes SHA-256: {sha256_file(dest_path)[:16]}...")
|
file_size = os.path.getsize(cache_path)
|
||||||
return True
|
_write_cache_meta(board_key, remote_tag, file_sha)
|
||||||
|
print(f" Downloaded {file_size:,} bytes SHA-256: {file_sha[:16]}...")
|
||||||
|
return cache_path, remote_tag
|
||||||
|
|
||||||
|
|
||||||
def _do_merge(output_path, esptool_cmd, bootloader, partitions, boot_app0, firmware):
|
def _do_merge(output_path, esptool_cmd, bootloader, partitions, boot_app0, firmware):
|
||||||
@@ -573,10 +687,11 @@ def main():
|
|||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog="""
|
epilog="""
|
||||||
Examples:
|
Examples:
|
||||||
python flash.py # App-only update, V4 (default)
|
python flash.py # Download latest & app-only update (V4)
|
||||||
python flash.py --board v3 # App-only update, V3
|
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 --full # Full flash with merged binary
|
||||||
python flash.py --download # Download latest release and flash
|
python flash.py --offline # Use cached/local firmware only
|
||||||
python flash.py --file firmware.bin # Flash a specific file
|
python flash.py --file firmware.bin # Flash a specific file
|
||||||
python flash.py --merge-only # Build merged binary for release
|
python flash.py --merge-only # Build merged binary for release
|
||||||
python flash.py --port /dev/ttyACM0 # Specify serial port
|
python flash.py --port /dev/ttyACM0 # Specify serial port
|
||||||
@@ -589,8 +704,10 @@ Examples:
|
|||||||
parser.add_argument("--file", "-f", help="Path to firmware binary to flash")
|
parser.add_argument("--file", "-f", help="Path to firmware binary to flash")
|
||||||
parser.add_argument("--port", "-p", help="Serial port (auto-detected if omitted)")
|
parser.add_argument("--port", "-p", help="Serial port (auto-detected if omitted)")
|
||||||
parser.add_argument("--baud", "-b", default=None, help="Baud rate (board-specific default)")
|
parser.add_argument("--baud", "-b", default=None, help="Baud rate (board-specific default)")
|
||||||
parser.add_argument("--download", "-d", action="store_true",
|
parser.add_argument("--release", "-r", default=None, metavar="TAG",
|
||||||
help="Download latest firmware from GitHub Releases")
|
help="Flash a specific release version (e.g. v1.0.12)")
|
||||||
|
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",
|
parser.add_argument("--merge-only", action="store_true",
|
||||||
help="Merge PlatformIO build output into single binary, don't flash")
|
help="Merge PlatformIO build output into single binary, don't flash")
|
||||||
parser.add_argument("--full", action="store_true",
|
parser.add_argument("--full", action="store_true",
|
||||||
@@ -669,11 +786,6 @@ Examples:
|
|||||||
print(f"Error: file not found: {firmware_path}")
|
print(f"Error: file not found: {firmware_path}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
elif args.download:
|
|
||||||
firmware_path = merged_fn
|
|
||||||
if not download_firmware(firmware_path):
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
elif args.merge_only:
|
elif args.merge_only:
|
||||||
if merge_firmware(merged_fn, esptool_cmd):
|
if merge_firmware(merged_fn, esptool_cmd):
|
||||||
print(f"\nDone! Flash with: python flash.py --board {_board} --file {merged_fn}")
|
print(f"\nDone! Flash with: python flash.py --board {_board} --file {merged_fn}")
|
||||||
@@ -681,10 +793,9 @@ Examples:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return
|
return
|
||||||
|
|
||||||
elif args.full:
|
elif args.full and not args.release and args.offline:
|
||||||
# Full flash: use or create merged binary
|
# Full flash, offline: use local PIO build or existing merged binary
|
||||||
if os.path.isfile(firmware_bin):
|
if os.path.isfile(firmware_bin):
|
||||||
# Build exists — (re-)merge
|
|
||||||
if os.path.isfile(merged_fn):
|
if os.path.isfile(merged_fn):
|
||||||
build_time = os.path.getmtime(firmware_bin)
|
build_time = os.path.getmtime(firmware_bin)
|
||||||
merge_time = os.path.getmtime(merged_fn)
|
merge_time = os.path.getmtime(merged_fn)
|
||||||
@@ -700,32 +811,57 @@ Examples:
|
|||||||
elif os.path.isfile(merged_fn):
|
elif os.path.isfile(merged_fn):
|
||||||
firmware_path = merged_fn
|
firmware_path = merged_fn
|
||||||
else:
|
else:
|
||||||
print("No firmware found for full flash!")
|
# Try cache
|
||||||
print()
|
cached = _cached_firmware_path(_board)
|
||||||
print("Options:")
|
if os.path.isfile(cached):
|
||||||
print(f" 1. Build with PlatformIO first: pio run -e {pio_env}")
|
firmware_path = cached
|
||||||
print(f" 2. Download from GitHub: python flash.py --board {_board} --download")
|
meta = _read_cache_meta(_board)
|
||||||
print(f" 3. Specify a file: python flash.py --board {_board} --file <path>")
|
print(f"Using cached firmware: {meta.get('tag', '?') if meta else '?'}")
|
||||||
sys.exit(1)
|
else:
|
||||||
|
print("No firmware found for full flash!")
|
||||||
|
print()
|
||||||
|
print("Options:")
|
||||||
|
print(f" 1. Build with PlatformIO first: pio run -e {pio_env}")
|
||||||
|
print(f" 2. Run without --offline to download from GitHub")
|
||||||
|
print(f" 3. Specify a file: python flash.py --board {_board} --file <path>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Default: app-only flash (preserves settings)
|
# Default path: fetch from GitHub (unless --offline)
|
||||||
if os.path.isfile(firmware_bin):
|
if not args.offline:
|
||||||
firmware_path = firmware_bin
|
fw_path, tag_or_err = fetch_firmware(_board, release_tag=args.release)
|
||||||
|
if fw_path:
|
||||||
|
firmware_path = fw_path
|
||||||
|
print(f"\n Release: {tag_or_err}")
|
||||||
|
else:
|
||||||
|
print(f"\n GitHub: {tag_or_err}")
|
||||||
|
print(" Falling back to local firmware...")
|
||||||
|
|
||||||
|
# Fall back to local PIO build output or cache
|
||||||
|
if not firmware_path:
|
||||||
|
if os.path.isfile(firmware_bin):
|
||||||
|
firmware_path = firmware_bin
|
||||||
|
print(f"Using local PlatformIO build: {firmware_bin}")
|
||||||
|
else:
|
||||||
|
cached = _cached_firmware_path(_board)
|
||||||
|
if os.path.isfile(cached):
|
||||||
|
firmware_path = cached
|
||||||
|
meta = _read_cache_meta(_board)
|
||||||
|
print(f"Using cached firmware: {meta.get('tag', '?') if meta else '?'}")
|
||||||
|
elif os.path.isfile(merged_fn):
|
||||||
|
firmware_path = merged_fn
|
||||||
|
print(f"Using local merged binary: {merged_fn}")
|
||||||
|
else:
|
||||||
|
print("No firmware found!")
|
||||||
|
print()
|
||||||
|
print("Options:")
|
||||||
|
print(f" 1. Build with PlatformIO first: pio run -e {pio_env}")
|
||||||
|
print(f" 2. Specify a file: python flash.py --board {_board} --file <path>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not args.full:
|
||||||
print(f"App-only update (preserves WiFi/boundary settings)")
|
print(f"App-only update (preserves WiFi/boundary settings)")
|
||||||
print(f" Use --full for a complete flash, or --erase for recovery.")
|
print(f" Use --full for a complete flash, or --erase for recovery.")
|
||||||
elif os.path.isfile(merged_fn):
|
|
||||||
firmware_path = merged_fn
|
|
||||||
print(f"No build output found, using merged binary: {merged_fn}")
|
|
||||||
print(f" Note: merged binary will overwrite bootloader + partitions.")
|
|
||||||
else:
|
|
||||||
print("No firmware found!")
|
|
||||||
print()
|
|
||||||
print("Options:")
|
|
||||||
print(f" 1. Build with PlatformIO first: pio run -e {pio_env}")
|
|
||||||
print(f" 2. Download from GitHub: python flash.py --board {_board} --download")
|
|
||||||
print(f" 3. Specify a file: python flash.py --board {_board} --file <path>")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Flash — reuse early-detected port if available
|
# Flash — reuse early-detected port if available
|
||||||
port = args.port or _early_port or find_serial_port()
|
port = args.port or _early_port or find_serial_port()
|
||||||
|
|||||||
Reference in New Issue
Block a user