From 840f51da16c9ed9e051be8c1c8b3356da0a5c21f Mon Sep 17 00:00:00 2001 From: James L Date: Sun, 22 Feb 2026 20:58:44 -0500 Subject: [PATCH] Add flash utility, display fixes, and comprehensive README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flash.py: standalone flash utility with serial port listing, merge-bin, GitHub Releases download, and esptool flash support - Display.h: hide LAN row when Local TCP disabled, show local TCP port instead of backbone port - README.md: comprehensive documentation — Quick Start with 3 flash options, OLED display layout, interface modes, routing customizations, path table fix, interface name uniqueness, hardware rationale (PSRAM/flash) - Release/boot_app0.bin: bundled for flash.py standalone use - .gitignore: exclude merged firmware binary build artifact --- .gitignore | 1 + Display.h | 24 ++- README.md | 81 +++++++-- Release/boot_app0.bin | Bin 0 -> 8192 bytes flash.py | 401 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 483 insertions(+), 24 deletions(-) create mode 100644 Release/boot_app0.bin create mode 100644 flash.py diff --git a/.gitignore b/.gitignore index afd39f3..a22554f 100755 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.hex *.pyc TODO +rnodethv4_firmware.bin Release/*.hex Release/*.zip Release/*.json diff --git a/Display.h b/Display.h index 747a463..b9c6395 100755 --- a/Display.h +++ b/Display.h @@ -840,17 +840,13 @@ void draw_stat_area() { stat_area.print("wan"); } - // Row 4 — LAN / local TCP server - if (!boundary_state.ap_tcp_enabled) { - stat_area.drawCircle(4, 33, 3, SSD1306_WHITE); - stat_area.setCursor(10, 36); - stat_area.print("LAN"); - } else if (boundary_state.ap_tcp_connected) { - stat_area.fillCircle(4, 33, 3, SSD1306_WHITE); - stat_area.setCursor(10, 36); - stat_area.print("LAN"); - } else { - stat_area.drawCircle(4, 33, 3, SSD1306_WHITE); + // Row 4 — LAN / local TCP server (hidden when disabled) + if (boundary_state.ap_tcp_enabled) { + if (boundary_state.ap_tcp_connected) { + stat_area.fillCircle(4, 33, 3, SSD1306_WHITE); + } else { + stat_area.drawCircle(4, 33, 3, SSD1306_WHITE); + } stat_area.setCursor(10, 36); stat_area.print("LAN"); } @@ -971,9 +967,11 @@ void draw_disp_area() { disp_area.print("No WiFi"); } - // Backbone port + // Local TCP server port (shown only when enabled) disp_area.setCursor(2, 55); - disp_area.printf("Port:%u", boundary_state.tcp_port); + if (boundary_state.ap_tcp_enabled) { + disp_area.printf("Port:%u", boundary_state.ap_tcp_port); + } // 1px separator after Port line disp_area.drawLine(0, 60, disp_area.width()-1, 60, SSD1306_WHITE); diff --git a/README.md b/README.md index 5db4a75..c2d7574 100755 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Built on [microReticulum](https://github.com/attermann/microReticulum) (a C++ po ## Hardware +The **Heltec WiFi LoRa 32 V4** was chosen because it ships standard with **2 MB PSRAM** and **16 MB flash** — enough headroom for the microReticulum transport tables, packet caching to flash storage, and the web-based configuration portal. Many other LoRa dev boards come with only 4–8 MB flash and no PSRAM, which would require significant compromises to the boundary node's caching and routing capabilities. + | Component | Spec | |-----------|------| | **Board** | Heltec WiFi LoRa 32 V4 | @@ -41,28 +43,63 @@ Built on [microReticulum](https://github.com/attermann/microReticulum) (a C++ po ## Quick Start -### Prerequisites +### Option A: Easy Flash (no PlatformIO required) -- [PlatformIO](https://platformio.org/) installed (via VS Code extension or CLI) -- Heltec WiFi LoRa 32 V4 connected via USB - -### Build & Flash +The easiest way to flash a pre-built firmware. You only need Python 3 and a USB cable. ```bash -# Clone this repo +# Install esptool (one time) +pip install esptool + +# Clone this repo (or download just flash.py + the firmware binary) +git clone https://github.com/jrl290/RNodeTHV4.git +cd RNodeTHV4 + +# Download latest firmware from GitHub Releases and flash +python flash.py --download + +# Or flash a local binary +python flash.py --file rnodethv4_firmware.bin +``` + +The flash utility will list all available serial ports and prompt you to choose one. If no ports are detected, you may need to hold the **BOOT** button while pressing **RESET** to enter download mode. + +### Option B: Build from Source (PlatformIO) + +For development or customization: + +```bash +# Prerequisites: PlatformIO installed (VS Code extension or CLI) + git clone https://github.com/jrl290/RNodeTHV4.git cd RNodeTHV4 # Build pio run -e heltec_V4_boundary -# Flash +# Flash (via PlatformIO) pio run -e heltec_V4_boundary -t upload +# Or create a merged binary and flash with the utility +python flash.py --merge-only # creates rnodethv4_firmware.bin +python flash.py # flash it + # Monitor serial output (optional) pio device monitor -e heltec_V4_boundary ``` +### Option C: Manual esptool Flash + +If you have the merged binary (`rnodethv4_firmware.bin`), you can flash it with a single esptool command: + +```bash +esptool.py --chip esp32s3 --port /dev/ttyACM0 --baud 921600 \ + write_flash -z --flash_mode qio --flash_freq 80m --flash_size 16MB \ + 0x0 rnodethv4_firmware.bin +``` + +Replace `/dev/ttyACM0` with your serial port (`/dev/cu.usbmodem*` on macOS, `COM3` on Windows). + On first boot (or if no configuration is found), the device automatically enters the **Configuration Portal**. ## Configuration Portal @@ -120,7 +157,7 @@ The 128×64 OLED is split into two panels: ● LORA ← filled circle = radio online ○ wifi ← unfilled circle = WiFi disconnected ● WAN ← filled = backbone TCP connected - ○ LAN ← unfilled = no local TCP clients + ● LAN ← filled = local TCP client connected ──────────────── Air:0.3% ← current LoRa airtime ▓▓▓▓▓ ||||||| ← battery, signal quality @@ -129,6 +166,7 @@ The 128×64 OLED is split into two panels: - **Filled circle (●)** = active/connected - **Unfilled circle (○)** = inactive/disconnected - Labels are UPPERCASE when active, lowercase when inactive (except LAN which is always uppercase) +- **LAN row is hidden** when the Local TCP Server is disabled in configuration — the remaining layout stays in place ### Right Panel — Device Info (64×64) @@ -138,10 +176,13 @@ The 128×64 OLED is split into two panels: SF7 125k ← spreading factor & bandwidth ──────────────── ← separator 192.168.1.42 ← WiFi IP address (or "No WiFi") - Port:4242 ← backbone TCP port + Port:4242 ← Local TCP server port ──────────────── ← separator ``` +- **Port** shows the Local TCP server port (the port local nodes connect to), not the backbone port +- **Port line is hidden** when the Local TCP Server is disabled + ## Interface Modes The firmware runs up to **three RNS interfaces** simultaneously, using different interface modes to control announce propagation and routing behavior: @@ -164,6 +205,11 @@ The TCP backbone connection uses `MODE_BOUNDARY` (`0x20`), a custom implementati If enabled, a TCP server on the WiFi network allows local Reticulum nodes to connect. It also uses Access Point mode, with the same announce filtering as LoRa. +**Implementation details:** +- Each TCP interface must have a **unique name** to produce a unique interface hash — the backbone uses `"TcpInterface"` and the local server uses `"LocalTcpInterface"`. Without distinct names, both interfaces produce the same hash, causing the interface map lookup to fail when routing packets. +- TCP interfaces are configured with a **10 Mbps bitrate**, which causes Reticulum's Transport to prefer TCP paths over LoRa paths (typically ~1–10 kbps) when both are available for the same destination. +- When the Local TCP Server is disabled, its status indicator (LAN) and port number are hidden from the OLED display. + ## Routing & Memory Customizations The ESP32-S3 has limited RAM compared to a desktop Reticulum node. Several customizations were made to the microReticulum library to operate reliably within these constraints: @@ -217,6 +263,18 @@ This was changed to call `unpack()` instead, which parses all packet fields AND > **Note:** `unpack()` only parses the plaintext routing envelope (destination hash, flags, hops, transport headers). It does not decrypt the end-to-end encrypted payload. Every Reticulum transport node performs equivalent header parsing during normal routing — this is standard behavior, not a security concern. +### Path Table Update Fix + +The C++ `std::map::insert()` method silently does nothing when a key already exists — unlike Python's `dict[key] = value` which replaces. The original microReticulum code used `insert()` to update path table entries, meaning stale LoRa paths were never replaced by newer TCP paths (or vice versa). + +This was fixed by calling `erase()` before `insert()`, ensuring updated path entries always replace stale ones. Without this fix, the boundary node would continue routing packets via an old interface even after a better path was learned. + +### Interface Name Uniqueness + +Each RNS interface must have a **unique name** because the name is hashed to produce the interface identifier used in path table lookups. If two interfaces share the same name, they produce the same hash, and `std::map` can only store one — causing the Transport layer to fail to resolve the correct outbound interface for packets. + +The TcpInterface constructor accepts an explicit `name` parameter: the backbone uses `"TcpInterface"` and the local server uses `"LocalTcpInterface"`. + ## Connecting to the Backbone ### Example: Connect to rmap.world @@ -265,8 +323,9 @@ Set the boundary node's **Local TCP Server** to **Enabled** (port 4242). | `RNode_Firmware.ino` | Main firmware — boundary mode initialization, interface setup, button handling | | `BoundaryMode.h` | Boundary state struct, EEPROM load/save, configuration defaults | | `BoundaryConfig.h` | Web-based captive portal for configuration | -| `TcpInterface.h` | TCP backbone interface (implements `RNS::InterfaceImpl`) with HDLC framing | +| `TcpInterface.h` | TCP interface for both backbone and local server (implements `RNS::InterfaceImpl`) with HDLC framing, unique naming, and 10 Mbps bitrate | | `Display.h` | OLED display layout — boundary-specific status page | +| `flash.py` | Flash utility — list serial ports, download from GitHub, merge & flash firmware | | `Boards.h` | Board variant definition for `heltec32v4_boundary` | | `platformio.ini` | Build targets: `heltec_V4_boundary` and `heltec_V4_boundary-local` | @@ -276,7 +335,7 @@ The firmware depends on [microReticulum](https://github.com/attermann/microRetic | File | Changes | |------|---------| -| `Transport.cpp` | Selective caching, default route forwarding, boundary-aware culling, `get_cached_packet()` unpack fix, memory limits | +| `Transport.cpp` | Selective caching, default route forwarding, boundary-aware culling, `get_cached_packet()` unpack fix, path table `erase()+insert()` fix, memory limits | | `Transport.h` | `MODE_BOUNDARY`, `PacketEntry`, `Callbacks`, `cull_path_table()`, configurable table sizes | | `Identity.cpp` | `_known_destinations_maxsize` = 24, `cull_known_destinations()` | | `Type.h` | `MODE_BOUNDARY` = 0x20, reduced `MAX_QUEUED_ANNOUNCES`, `MAX_RECEIPTS`, shorter timeouts | diff --git a/Release/boot_app0.bin b/Release/boot_app0.bin new file mode 100644 index 0000000000000000000000000000000000000000..13562cabb9648287fdf70d2a22789fdf1e4156b4 GIT binary patch literal 8192 zcmeI#u?+wq2n0Z!&B7Ip%ZdwNPjZydJlFk*h+E9ra}_6R0t5&UAV7cs0RjXF5FkLH gk-)3}W&dyVhNuJx5FkK+009C72oNAZfWSu}0Te{nn*aa+ literal 0 HcmV?d00001 diff --git a/flash.py b/flash.py new file mode 100644 index 0000000..76cc1e9 --- /dev/null +++ b/flash.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +""" +RNodeTHV4 Flash Utility + +Flash the RNodeTHV4 boundary node firmware to a Heltec WiFi LoRa 32 V4. +No PlatformIO required — just Python 3 and a USB cable. + +Usage: + # Flash a pre-built merged binary (from GitHub Releases or local build) + python flash.py + + # Flash a specific file + python flash.py --file rnodethv4_firmware.bin + + # Download latest from GitHub and flash + python flash.py --download + + # Specify serial port manually + python flash.py --port /dev/ttyACM0 + + # Just build the merged binary (requires PlatformIO build output) + python flash.py --merge-only +""" + +import argparse +import glob +import hashlib +import os +import platform +import shutil +import subprocess +import sys +import time + +# ── Configuration ────────────────────────────────────────────────────────────── + +CHIP = "esp32s3" +FLASH_MODE = "qio" +FLASH_FREQ = "80m" +FLASH_SIZE = "16MB" +BAUD_RATE = "921600" +MERGED_FILENAME = "rnodethv4_firmware.bin" +GITHUB_REPO = "jrl290/RNodeTHV4" + +# Flash addresses for ESP32-S3 Arduino framework +BOOTLOADER_ADDR = 0x0000 +PARTITIONS_ADDR = 0x8000 +BOOT_APP0_ADDR = 0xe000 +APP_ADDR = 0x10000 + +# PlatformIO build output paths (relative to project root) +BUILD_DIR = ".pio/build/heltec_V4_boundary" +BOOTLOADER_BIN = os.path.join(BUILD_DIR, "bootloader.bin") +PARTITIONS_BIN = os.path.join(BUILD_DIR, "partitions.bin") +FIRMWARE_BIN = os.path.join(BUILD_DIR, "rnode_firmware_heltec32v4_boundary.bin") +BOOT_APP0_BIN = os.path.expanduser( + "~/.platformio/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin" +) + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +def find_esptool(): + """Find esptool.py — bundled, pip-installed, or PlatformIO's copy.""" + # 1. Bundled in Release/ + bundled = os.path.join(os.path.dirname(__file__), "Release", "esptool", "esptool.py") + if os.path.isfile(bundled): + return [sys.executable, bundled] + + # 2. pip-installed esptool + if shutil.which("esptool.py"): + return ["esptool.py"] + if shutil.which("esptool"): + return ["esptool"] + + # 3. PlatformIO's esptool + pio_esptool = os.path.expanduser( + "~/.platformio/packages/tool-esptoolpy/esptool.py" + ) + if os.path.isfile(pio_esptool): + return [sys.executable, pio_esptool] + + return None + + +def find_serial_port(): + """List available serial ports and let the user choose.""" + system = platform.system() + + # Gather ports from glob patterns + if system == "Darwin": + patterns = ["/dev/cu.usbmodem*", "/dev/tty.usbmodem*", + "/dev/cu.usbserial*", "/dev/cu.SLAB*"] + elif system == "Linux": + patterns = ["/dev/ttyACM*", "/dev/ttyUSB*"] + else: + patterns = [] + + ports = [] + for pattern in patterns: + ports.extend(glob.glob(pattern)) + + # Also try pyserial's port enumeration (works on all platforms including Windows) + try: + import serial.tools.list_ports + for port in serial.tools.list_ports.comports(): + if port.device not in ports: + ports.append(port.device) + except ImportError: + pass + + # Sort for consistent ordering + ports.sort() + + if not ports: + return None + + print("\nAvailable serial ports:") + for i, p in enumerate(ports): + print(f" [{i+1}] {p}") + print() + + while True: + try: + choice = input(f"Select port [1-{len(ports)}]: ").strip() + idx = int(choice) - 1 + if 0 <= idx < len(ports): + return ports[idx] + except (ValueError, EOFError): + pass + print("Invalid selection, try again.") + + +def sha256_file(path): + """Compute SHA-256 hash of a file.""" + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def download_firmware(dest_path): + """Download the latest merged firmware from GitHub Releases.""" + try: + from urllib.request import urlretrieve, urlopen + import json + except ImportError: + print("Error: Python urllib not available.") + return False + + api_url = f"https://api.github.com/repos/{GITHUB_REPO}/releases/latest" + print(f"Checking latest release from {GITHUB_REPO}...") + + try: + with urlopen(api_url) as resp: + release = json.loads(resp.read()) + except Exception as e: + print(f"Error fetching release info: {e}") + return False + + # Find the merged firmware asset + asset_url = None + for asset in release.get("assets", []): + if asset["name"] == MERGED_FILENAME: + asset_url = asset["browser_download_url"] + break + + if not asset_url: + print(f"Error: '{MERGED_FILENAME}' not found in latest release ({release.get('tag_name', '?')}).") + print("Available assets:", [a["name"] for a in release.get("assets", [])]) + return False + + print(f"Downloading {release['tag_name']} / {MERGED_FILENAME}...") + try: + urlretrieve(asset_url, dest_path) + except Exception as e: + print(f"Download failed: {e}") + return False + + size = os.path.getsize(dest_path) + print(f"Downloaded {size:,} bytes SHA-256: {sha256_file(dest_path)[:16]}...") + return True + + +def merge_firmware(output_path, esptool_cmd): + """Merge bootloader + partitions + boot_app0 + app into a single binary.""" + # Check all required files exist + required = { + "bootloader": BOOTLOADER_BIN, + "partitions": PARTITIONS_BIN, + "firmware": FIRMWARE_BIN, + } + + # boot_app0 can come from PlatformIO or be bundled + boot_app0 = BOOT_APP0_BIN + if not os.path.isfile(boot_app0): + # Check if bundled in Release/ + alt = os.path.join(os.path.dirname(__file__), "Release", "boot_app0.bin") + if os.path.isfile(alt): + boot_app0 = alt + else: + print(f"Error: boot_app0.bin not found at {BOOT_APP0_BIN}") + print(" Run 'pio run -e heltec_V4_boundary' first, or install PlatformIO.") + return False + required["boot_app0"] = boot_app0 + + for name, path in required.items(): + if not os.path.isfile(path): + print(f"Error: {name} not found: {path}") + print("Run 'pio run -e heltec_V4_boundary' to build first.") + return False + + print("Merging firmware components...") + print(f" Bootloader: {BOOTLOADER_BIN} @ 0x{BOOTLOADER_ADDR:04x}") + print(f" Partitions: {PARTITIONS_BIN} @ 0x{PARTITIONS_ADDR:04x}") + print(f" boot_app0: {boot_app0} @ 0x{BOOT_APP0_ADDR:04x}") + print(f" Firmware: {FIRMWARE_BIN} @ 0x{APP_ADDR:05x}") + + cmd = esptool_cmd + [ + "--chip", CHIP, + "merge_bin", + "--flash_mode", FLASH_MODE, + "--flash_freq", FLASH_FREQ, + "--flash_size", FLASH_SIZE, + "-o", output_path, + f"0x{BOOTLOADER_ADDR:x}", BOOTLOADER_BIN, + f"0x{PARTITIONS_ADDR:x}", PARTITIONS_BIN, + f"0x{BOOT_APP0_ADDR:x}", boot_app0, + f"0x{APP_ADDR:x}", FIRMWARE_BIN, + ] + + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"Error merging: {result.stderr}{result.stdout}") + return False + + size = os.path.getsize(output_path) + print(f"\nMerged binary: {output_path} ({size:,} bytes)") + print(f"SHA-256: {sha256_file(output_path)[:16]}...") + return True + + +def flash_firmware(firmware_path, port, esptool_cmd, baud=BAUD_RATE): + """Flash firmware to the device.""" + print(f"\nFlashing {firmware_path} to {port}...") + print(f" Chip: {CHIP} Baud: {baud} Flash: {FLASH_SIZE}\n") + + # Determine if this is a merged binary (flash at 0x0) or app-only (flash at 0x10000) + size = os.path.getsize(firmware_path) + if size > 1500000: + # Merged binary — includes bootloader, partitions, etc. + flash_addr = f"0x{BOOTLOADER_ADDR:x}" + else: + # App-only binary + flash_addr = f"0x{APP_ADDR:x}" + + cmd = esptool_cmd + [ + "--chip", CHIP, + "--port", port, + "--baud", baud, + "--before", "default_reset", + "--after", "hard_reset", + "write_flash", + "-z", + "--flash_mode", FLASH_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 + + +# ── Main ─────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="RNodeTHV4 Flash Utility — flash boundary node firmware to Heltec V4", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python flash.py # Flash local merged binary + python flash.py --download # Download latest release and flash + python flash.py --file firmware.bin # Flash a specific file + python flash.py --merge-only # Build merged binary from PlatformIO output + python flash.py --port /dev/ttyACM0 # Specify serial port + """, + ) + 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("--baud", "-b", default=BAUD_RATE, help=f"Baud rate (default: {BAUD_RATE})") + parser.add_argument("--download", "-d", action="store_true", + help="Download latest firmware from GitHub Releases") + parser.add_argument("--merge-only", action="store_true", + help="Merge PlatformIO build output into single binary, don't flash") + parser.add_argument("--no-merge", action="store_true", + help="Skip merge step, use existing merged binary or --file") + + args = parser.parse_args() + baud = args.baud + + print("╔══════════════════════════════════════════╗") + print("║ RNodeTHV4 Flash Utility ║") + print("║ Heltec WiFi LoRa 32 V4 Boundary Node ║") + print("╚══════════════════════════════════════════╝") + print() + + # Find esptool + esptool_cmd = find_esptool() + if not esptool_cmd: + print("Error: esptool not found!") + print("Install it with: pip install esptool") + sys.exit(1) + print(f"Using esptool: {' '.join(esptool_cmd)}") + + # Determine firmware file + firmware_path = None + + if args.file: + firmware_path = args.file + if not os.path.isfile(firmware_path): + print(f"Error: file not found: {firmware_path}") + sys.exit(1) + + elif args.download: + firmware_path = MERGED_FILENAME + if not download_firmware(firmware_path): + sys.exit(1) + + elif args.merge_only: + if merge_firmware(MERGED_FILENAME, esptool_cmd): + print(f"\nDone! Flash with: python flash.py --file {MERGED_FILENAME}") + else: + sys.exit(1) + return + + else: + # Try to find or create a merged binary + if os.path.isfile(MERGED_FILENAME) and not args.no_merge: + # Check if PlatformIO build is newer + if os.path.isfile(FIRMWARE_BIN): + build_time = os.path.getmtime(FIRMWARE_BIN) + merge_time = os.path.getmtime(MERGED_FILENAME) + if build_time > merge_time: + print("Build output is newer than merged binary, re-merging...") + if not merge_firmware(MERGED_FILENAME, esptool_cmd): + sys.exit(1) + firmware_path = MERGED_FILENAME + elif os.path.isfile(FIRMWARE_BIN): + # Build exists but no merged binary — create one + print("Found PlatformIO build output, creating merged binary...") + if not merge_firmware(MERGED_FILENAME, esptool_cmd): + sys.exit(1) + firmware_path = MERGED_FILENAME + elif os.path.isfile(MERGED_FILENAME): + firmware_path = MERGED_FILENAME + else: + print("No firmware found!") + print() + print("Options:") + print(" 1. Build with PlatformIO first: pio run -e heltec_V4_boundary") + print(" 2. Download from GitHub: python flash.py --download") + print(" 3. Specify a file: python flash.py --file ") + sys.exit(1) + + # Flash + port = args.port or find_serial_port() + if not port: + print("\nError: No serial port detected!") + print("Connect your Heltec V4 via USB and try again,") + print("or specify manually: python flash.py --port /dev/ttyACM0") + sys.exit(1) + + print(f"\nSerial port: {port}") + print(f"Firmware: {firmware_path} ({os.path.getsize(firmware_path):,} bytes)") + print() + + confirm = input("Flash firmware? [Y/n] ").strip().lower() + if confirm and confirm != "y": + print("Aborted.") + sys.exit(0) + + if flash_firmware(firmware_path, port, esptool_cmd, baud): + print() + print("╔══════════════════════════════════════════╗") + print("║ Flash complete! ║") + print("║ Device will reboot automatically. ║") + print("║ ║") + print("║ On first boot, hold PRG > 5s to enter ║") + print("║ the configuration portal. ║") + print("╚══════════════════════════════════════════╝") + else: + print("\nFlash FAILED. Check connection and try again.") + print("You may need to hold BOOT while pressing RESET.") + sys.exit(1) + + +if __name__ == "__main__": + main()