diff --git a/Boards.h b/Boards.h index 38262d8..e319b63 100755 --- a/Boards.h +++ b/Boards.h @@ -349,7 +349,11 @@ #define HAS_DISPLAY true #define HAS_WIFI true #define HAS_BLUETOOTH false - #define HAS_BLE true + #ifdef BOUNDARY_MODE + #define HAS_BLE false + #else + #define HAS_BLE true + #endif #define HAS_PMU true #define HAS_CONSOLE true #define HAS_EEPROM true diff --git a/README.md b/README.md index c2d7574..d57fb8e 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# RNodeTHV4 — Reticulum Boundary Node for Heltec WiFi LoRa 32 V4 +# RNodeTHV4 — Reticulum Boundary Node for Heltec WiFi LoRa 32 V3 / V4 -A custom firmware for the **Heltec WiFi LoRa 32 V4** (ESP32-S3 + SX1262) that operates as a **Boundary Node** — bridging a local LoRa radio network with a remote TCP/IP backbone (such as [rmap.world](https://rmap.world)) over WiFi. +A custom firmware for the **Heltec WiFi LoRa 32 V3** and **V4** (ESP32-S3 + SX1262) that operates as a **Boundary Node** — bridging a local LoRa radio network with a remote TCP/IP backbone (such as [rmap.world](https://rmap.world)) over WiFi. ``` Android / Sideband Remote @@ -28,18 +28,22 @@ Built on [microReticulum](https://github.com/attermann/microReticulum) (a C++ po - **Optional local TCP server** — serve local devices on your WiFi in addition to the backbone connection - **Automatic reconnection** — WiFi and TCP connections recover from drops with exponential backoff - **ESP32 memory-optimized** — table sizes, timeouts, and caching tuned for the constrained MCU environment +- **Dual board support** — supports both Heltec V3 (8MB flash, 8MB PSRAM) and V4 (16MB flash, 2MB PSRAM) with automatic board detection ## 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. +Both the **Heltec WiFi LoRa 32 V3** and **V4** are supported. These boards were chosen because they ship with PSRAM and ample 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 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 | -| **MCU** | ESP32-S3, 2MB PSRAM, 16MB Flash | -| **Radio** | SX1262 + GC1109 PA (up to 28 dBm) | -| **Display** | SSD1306 OLED 128×64 | -| **WiFi** | 2.4 GHz 802.11 b/g/n | +| Component | Heltec V3 | Heltec V4 | +|-----------|-----------|----------| +| **MCU** | ESP32-S3 | ESP32-S3 | +| **Flash** | 8 MB | 16 MB | +| **PSRAM** | 8 MB (QSPI) | 2 MB (QSPI) | +| **Radio** | SX1262 | SX1262 + GC1109 PA | +| **TX Power** | Up to 22 dBm | Up to 28 dBm | +| **Display** | SSD1306 OLED 128×64 | SSD1306 OLED 128×64 | +| **WiFi** | 2.4 GHz 802.11 b/g/n | 2.4 GHz 802.11 b/g/n | +| **USB** | Native USB CDC | Native USB CDC | ## Quick Start @@ -56,13 +60,18 @@ git clone https://github.com/jrl290/RNodeTHV4.git cd RNodeTHV4 # Download latest firmware from GitHub Releases and flash +# (auto-detects V3 vs V4 from flash size) python flash.py --download +# Or specify board explicitly +python flash.py --download --board v3 +python flash.py --download --board v4 + # 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. +The flash utility auto-detects whether a V3 or V4 is connected by querying the flash size (8MB = V3, 16MB = V4). You can override with `--board v3` or `--board v4`. It 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) @@ -74,15 +83,18 @@ For development or customization: git clone https://github.com/jrl290/RNodeTHV4.git cd RNodeTHV4 -# Build +# Build for V4 pio run -e heltec_V4_boundary +# Build for V3 +pio run -e heltec_V3_boundary + # 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 +python flash.py --merge-only # creates merged firmware bin +python flash.py # flash it (auto-detects board) # Monitor serial output (optional) pio device monitor -e heltec_V4_boundary @@ -326,8 +338,8 @@ Set the boundary node's **Local TCP Server** to **Enabled** (port 4242). | `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` | +| `Boards.h` | Board variant definitions for V3 and V4 | +| `platformio.ini` | Build targets: `heltec_V3_boundary`, `heltec_V4_boundary`, and `heltec_V4_boundary-local` | ### Library Patches @@ -340,12 +352,12 @@ The firmware depends on [microReticulum](https://github.com/attermann/microRetic | `Identity.cpp` | `_known_destinations_maxsize` = 24, `cull_known_destinations()` | | `Type.h` | `MODE_BOUNDARY` = 0x20, reduced `MAX_QUEUED_ANNOUNCES`, `MAX_RECEIPTS`, shorter timeouts | -### Memory Usage (typical) +### Memory Usage (typical, V4) | Resource | Used | Available | -|----------|------|-----------| +|----------|------|----------| | RAM | ~21.7% | 320 KB | -| Flash | ~18.1% | 16 MB | +| Flash | ~18.4% | 16 MB | | PSRAM | Dynamic | 2 MB | ## License diff --git a/RNode_Firmware.ino b/RNode_Firmware.ino index ae070db..b9afe48 100755 --- a/RNode_Firmware.ino +++ b/RNode_Firmware.ino @@ -635,7 +635,8 @@ void setup() { RNS::Transport::set_transmit_packet_callback(on_transmit_packet); Serial.write("Starting RNS...\r\n"); - RNS::loglevel(RNS::LOG_TRACE); + RNS::loglevel(RNS::LOG_VERBOSE); + //RNS::loglevel(RNS::LOG_TRACE); //RNS::loglevel(RNS::LOG_MEM); HEAD("Registering LoRA Interface...", RNS::LOG_TRACE); @@ -701,6 +702,9 @@ void setup() { 0, "LocalTcpInterface" ); + // rnsd can be quiet for long stretches — use 10 min timeout + // to prevent unnecessary reconnection cycles that leak lwIP memory + local_tcp_interface_ptr->setReadTimeout(600000); local_tcp_rns_interface = local_tcp_interface_ptr; local_tcp_rns_interface.mode(RNS::Type::Interface::MODE_ACCESS_POINT); RNS::Transport::register_interface(local_tcp_rns_interface); diff --git a/TcpInterface.h b/TcpInterface.h index 46b4155..f1bd147 100755 --- a/TcpInterface.h +++ b/TcpInterface.h @@ -17,6 +17,7 @@ #ifdef BOUNDARY_MODE #include +#include // SO_LINGER — force RST to free lwIP PCBs immediately #include #include #include @@ -75,6 +76,7 @@ public: _last_reconnect(0), _last_keepalive(0), _reconnect_interval(TCP_IF_RECONNECT_MIN), + _read_timeout(TCP_IF_READ_TIMEOUT), _resolved_ip((uint32_t)0), _consecutive_failures(0), _started(false) @@ -128,7 +130,16 @@ public: void stop() { for (int i = 0; i < TCP_IF_MAX_CLIENTS; i++) { if (_clients[i].active) { + // Force RST to free lwIP PCBs immediately (no TIME_WAIT) + int fd = _clients[i].client.fd(); + if (fd >= 0) { + struct linger lin; + lin.l_onoff = 1; + lin.l_linger = 0; + setsockopt(fd, SOL_SOCKET, SO_LINGER, &lin, sizeof(lin)); + } _clients[i].client.stop(); + _clients[i].client = WiFiClient(); _clients[i].active = false; } } @@ -185,25 +196,15 @@ public: if (!_clients[i].active) continue; if (!_clients[i].client.connected()) { - Serial.printf("[TcpIF] Client %d disconnected\r\n", i); - _clients[i].client.stop(); - _clients[i].active = false; - _clients[i].in_frame = false; - _clients[i].escape = false; - _clients[i].rxlen = 0; - _num_clients--; + _cleanup_client(i, "disconnected"); continue; } - // Check read timeout - if (_clients[i].last_activity > 0 && - (millis() - _clients[i].last_activity) > TCP_IF_READ_TIMEOUT) { - Serial.printf("[TcpIF] Client %d read timeout\r\n", i); - _clients[i].client.stop(); - _clients[i].active = false; - _clients[i].in_frame = false; - _clients[i].rxlen = 0; - _num_clients--; + // Check read timeout (0 = disabled) + if (_read_timeout > 0 && + _clients[i].last_activity > 0 && + (millis() - _clients[i].last_activity) > _read_timeout) { + _cleanup_client(i, "read timeout"); continue; } @@ -220,6 +221,7 @@ public: int clientCount() const { return _num_clients; } bool isStarted() const { return _started; } bool isConnected() const { return _num_clients > 0; } + void setReadTimeout(uint32_t timeout_ms) { _read_timeout = timeout_ms; } protected: // ─── RNS InterfaceImpl: outgoing packet from RNS Transport ─────────────── @@ -248,12 +250,7 @@ protected: if (_clients[i].active && _clients[i].client.connected()) { size_t written = _clients[i].client.write(frame_buf, flen); if (written == 0) { - Serial.printf("[TcpIF] Write failed on client %d, dropping\r\n", i); - _clients[i].client.stop(); - _clients[i].active = false; - _clients[i].in_frame = false; - _clients[i].rxlen = 0; - _num_clients--; + _cleanup_client(i, "write failed"); } } } @@ -270,6 +267,38 @@ protected: } private: + // ─── Cleanup a client slot, freeing all lwIP resources ─────────────────── + void _cleanup_client(int idx, const char* reason) { + TcpClient& c = _clients[idx]; + if (!c.active) return; + + uint32_t heap_before = ESP.getFreeHeap(); + + // Set SO_LINGER with timeout 0: forces RST instead of FIN, + // which skips TIME_WAIT and immediately frees the lwIP PCB + // and all associated TCP send/receive buffers (~2-4 KB each). + int fd = c.client.fd(); + if (fd >= 0) { + struct linger lin; + lin.l_onoff = 1; + lin.l_linger = 0; + setsockopt(fd, SOL_SOCKET, SO_LINGER, &lin, sizeof(lin)); + } + + c.client.stop(); + c.client = WiFiClient(); // Release any residual shared_ptr state + c.active = false; + c.in_frame = false; + c.escape = false; + c.rxlen = 0; + _num_clients--; + + uint32_t heap_after = ESP.getFreeHeap(); + Serial.printf("[TcpIF] Client %d %s (heap: %u -> %u, delta: %+d)\r\n", + idx, reason, heap_before, heap_after, + (int)(heap_after - heap_before)); + } + // ─── HDLC byte-level deframing ────────────────────────────────────────── void _hdlc_deframe(int idx, uint8_t byte) { TcpClient& c = _clients[idx]; @@ -306,6 +335,18 @@ private: // Find a free slot for (int i = 0; i < TCP_IF_MAX_CLIENTS; i++) { if (!_clients[i].active) { + // Defensive: force-release any residual lwIP resources in this slot + // before assigning the new client (prevents PCB/buffer leaks) + int fd = _clients[i].client.fd(); + if (fd >= 0) { + struct linger lin; + lin.l_onoff = 1; + lin.l_linger = 0; + setsockopt(fd, SOL_SOCKET, SO_LINGER, &lin, sizeof(lin)); + _clients[i].client.stop(); + } + _clients[i].client = WiFiClient(); // Reset to clean state + _clients[i].client = newClient; _clients[i].client.setNoDelay(true); _clients[i].client.setTimeout(TCP_IF_WRITE_TIMEOUT / 1000); @@ -399,6 +440,7 @@ private: uint32_t _last_reconnect; uint32_t _last_keepalive; uint32_t _reconnect_interval; + uint32_t _read_timeout; IPAddress _resolved_ip; uint16_t _consecutive_failures; bool _started; diff --git a/flash.py b/flash.py index edb47b5..3204780 100755 --- a/flash.py +++ b/flash.py @@ -2,16 +2,19 @@ """ RNodeTHV4 Flash Utility -Flash the RNodeTHV4 boundary node firmware to a Heltec WiFi LoRa 32 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. Default mode flashes only the app partition (0x10000), preserving bootloader, partition table, NVS, and EEPROM settings. Usage: - # Update firmware (preserves WiFi/boundary settings) + # Update firmware — V4 (default) python flash.py + # Update firmware — V3 + python flash.py --board v3 + # Full flash with merged binary (overwrites everything) python flash.py --full @@ -43,9 +46,6 @@ import time 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 @@ -54,11 +54,61 @@ 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") +# ── Board profiles ───────────────────────────────────────────────────────────── +# Each board defines its PIO env, flash size, baud rate, firmware binary name, +# and merged binary name. + +BOARD_PROFILES = { + "v4": { + "name": "Heltec WiFi LoRa 32 V4", + "pio_env": "heltec_V4_boundary", + "build_dir": ".pio/build/heltec_V4_boundary", + "firmware_bin": "rnode_firmware_heltec32v4_boundary.bin", + "merged_filename": "rnodethv4_firmware.bin", + "flash_size": "16MB", + "baud_rate": "921600", + }, + "v3": { + "name": "Heltec WiFi LoRa 32 V3", + "pio_env": "heltec_V3_boundary", + "build_dir": ".pio/build/heltec_V3_boundary", + "firmware_bin": "rnode_firmware_heltec32v3.bin", + "merged_filename": "rnodethv3_firmware.bin", + "flash_size": "8MB", + "baud_rate": "460800", + }, +} +DEFAULT_BOARD = "v4" + +# Active board profile (set in main() from --board arg) +_board = None + +def board_profile(): + return BOARD_PROFILES[_board or DEFAULT_BOARD] + +def BUILD_DIR(): + return board_profile()["build_dir"] + +def BOOTLOADER_BIN(): + return os.path.join(BUILD_DIR(), "bootloader.bin") + +def PARTITIONS_BIN(): + return os.path.join(BUILD_DIR(), "partitions.bin") + +def FIRMWARE_BIN(): + return os.path.join(BUILD_DIR(), board_profile()["firmware_bin"]) + +def FLASH_SIZE(): + return board_profile()["flash_size"] + +def BAUD_RATE(): + return board_profile()["baud_rate"] + +def MERGED_FILENAME(): + return board_profile()["merged_filename"] + +def PIO_ENV(): + return board_profile()["pio_env"] # ESP32 partition table magic bytes (first two bytes of a partition table entry) PARTITION_TABLE_MAGIC = b'\xaa\x50' @@ -94,6 +144,16 @@ def _find_in_platformio_or_release(build_path, release_name): return None +# Forward-compatible aliases (these are now functions, not constants) +def _bootloader_bin(): + return BOOTLOADER_BIN() + +def _partitions_bin(): + return PARTITIONS_BIN() + +def _firmware_bin(): + return FIRMWARE_BIN() + def find_boot_app0(): """Find boot_app0.bin from PlatformIO framework packages. @@ -126,16 +186,77 @@ def find_boot_app0(): def find_bootloader(): """Find bootloader.bin from PlatformIO build output or Release/ bundle.""" - return _find_in_platformio_or_release(BOOTLOADER_BIN, "bootloader.bin") + return _find_in_platformio_or_release(BOOTLOADER_BIN(), "bootloader.bin") def find_partitions(): """Find partitions.bin from PlatformIO build output or Release/ bundle.""" - return _find_in_platformio_or_release(PARTITIONS_BIN, "partitions.bin") + return _find_in_platformio_or_release(PARTITIONS_BIN(), "partitions.bin") BOOT_APP0_BIN = find_boot_app0() +# ── Board auto-detection ─────────────────────────────────────────────────────── + +# Map detected flash sizes to board keys +_FLASH_SIZE_TO_BOARD = { + "16MB": "v4", + "8MB": "v3", +} + +def detect_board(port, esptool_cmd): + """Auto-detect which Heltec board is connected by querying flash size. + + Runs ``esptool.py flash_id`` and parses the output for: + - Detected flash size (16MB → V4, 8MB → V3) + - Chip type (ESP32-S3 expected) + - Features (PSRAM size, WiFi, BLE) + + Returns a tuple (board_key, info_dict) on success, or (None, reason) on + failure. ``board_key`` is "v3" or "v4". + """ + cmd = esptool_cmd + ["--port", port, "flash_id"] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + except subprocess.TimeoutExpired: + return None, "esptool timed out (device not responding?)" + except Exception as e: + return None, str(e) + + output = result.stdout + result.stderr + if result.returncode != 0: + return None, f"esptool flash_id failed:\n{output.strip()}" + + # Parse key fields + info = {} + for line in output.splitlines(): + line = line.strip() + if line.startswith("Chip is "): + info["chip"] = line[len("Chip is "):] + elif line.startswith("Features:"): + info["features"] = line[len("Features:"):].strip() + elif line.startswith("Detected flash size:"): + info["flash_size"] = line.split(":")[-1].strip() + elif line.startswith("MAC:"): + info["mac"] = line.split(":")[-5:] # last 5 colon-groups + info["mac"] = line[len("MAC:"):].strip() + elif line.startswith("Crystal is"): + info["crystal"] = line[len("Crystal is"):].strip() + + flash_size = info.get("flash_size") + if not flash_size: + return None, f"Could not parse flash size from esptool output:\n{output.strip()}" + + board_key = _FLASH_SIZE_TO_BOARD.get(flash_size) + if not board_key: + return None, ( + f"Unknown flash size '{flash_size}' — expected 16MB (V4) or 8MB (V3).\n" + f"Use --board v3 or --board v4 to specify manually." + ) + + return board_key, info + + # ── Helpers ──────────────────────────────────────────────────────────────────── def find_esptool(): @@ -259,16 +380,16 @@ def download_firmware(dest_path): # Find the merged firmware asset asset_url = None for asset in release.get("assets", []): - if asset["name"] == MERGED_FILENAME: + 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(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}...") + print(f"Downloading {release['tag_name']} / {MERGED_FILENAME()}...") try: urlretrieve(asset_url, dest_path) except Exception as e: @@ -293,7 +414,7 @@ def _do_merge(output_path, esptool_cmd, bootloader, partitions, boot_app0, firmw "merge_bin", "--flash_mode", FLASH_MODE, "--flash_freq", FLASH_FREQ, - "--flash_size", FLASH_SIZE, + "--flash_size", FLASH_SIZE(), "-o", output_path, f"0x{BOOTLOADER_ADDR:x}", bootloader, f"0x{PARTITIONS_ADDR:x}", partitions, @@ -321,11 +442,11 @@ def merge_firmware(output_path, esptool_cmd): bootloader = find_bootloader() partitions = find_partitions() boot_app0 = BOOT_APP0_BIN - firmware = FIRMWARE_BIN + firmware = FIRMWARE_BIN() missing = [] - if not bootloader: missing.append(("bootloader", BOOTLOADER_BIN)) - if not partitions: missing.append(("partitions", PARTITIONS_BIN)) + if not bootloader: missing.append(("bootloader", BOOTLOADER_BIN())) + if not partitions: missing.append(("partitions", PARTITIONS_BIN())) if not boot_app0: missing.append(("boot_app0", "(not found)")) if not os.path.isfile(firmware): missing.append(("firmware", firmware)) @@ -333,7 +454,7 @@ def merge_firmware(output_path, esptool_cmd): if missing: for name, path in missing: print(f"Error: {name} not found: {path}") - print("Run 'pio run -e heltec_V4_boundary' to build first.") + print(f"Run 'pio run -e {PIO_ENV()}' to build first.") return False return _do_merge(output_path, esptool_cmd, bootloader, partitions, boot_app0, firmware) @@ -360,7 +481,7 @@ def auto_merge_app_binary(app_binary_path, esptool_cmd): if missing: print(f"Cannot auto-merge: missing {', '.join(missing)}") print("Place them in the Release/ folder alongside flash.py, or") - print("build with PlatformIO: pio run -e heltec_V4_boundary") + print(f"build with PlatformIO: pio run -e {PIO_ENV()}") return None # Create merged binary next to the app binary @@ -406,10 +527,13 @@ def reset_to_bootloader(port): return True -def flash_firmware(firmware_path, port, esptool_cmd, baud=BAUD_RATE): +def flash_firmware(firmware_path, port, esptool_cmd, baud=None): """Flash firmware to the device.""" + if baud is None: + baud = BAUD_RATE() + flash_size = FLASH_SIZE() print(f"\nFlashing {firmware_path} to {port}...") - print(f" Chip: {CHIP} Baud: {baud} Flash: {FLASH_SIZE}\n") + 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) is_merged = is_merged_binary(firmware_path) @@ -431,7 +555,7 @@ def flash_firmware(firmware_path, port, esptool_cmd, baud=BAUD_RATE): "-z", "--flash_mode", FLASH_MODE, "--flash_freq", FLASH_FREQ, - "--flash_size", FLASH_SIZE, + "--flash_size", flash_size, flash_addr, firmware_path, ] @@ -443,12 +567,14 @@ def flash_firmware(firmware_path, port, esptool_cmd, baud=BAUD_RATE): # ── Main ─────────────────────────────────────────────────────────────────────── def main(): + global _board parser = argparse.ArgumentParser( - description="RNodeTHV4 Flash Utility — flash boundary node firmware to Heltec V4", + description="RNodeTHV4 Flash Utility — flash boundary node firmware to Heltec V3/V4", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: - python flash.py # App-only update (preserves settings) + python flash.py # App-only update, V4 (default) + python flash.py --board v3 # App-only update, V3 python flash.py --full # Full flash with merged binary python flash.py --download # Download latest release and flash python flash.py --file firmware.bin # Flash a specific file @@ -457,9 +583,12 @@ Examples: python flash.py --erase --full # Erase flash, then full flash """, ) + parser.add_argument("--board", choices=["v3", "v4"], default=None, + help="Target board: v3 (Heltec V3) or v4 (Heltec V4). " + "Auto-detected from connected device if omitted.") 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("--baud", "-b", default=None, help="Baud rate (board-specific default)") parser.add_argument("--download", "-d", action="store_true", help="Download latest firmware from GitHub Releases") parser.add_argument("--merge-only", action="store_true", @@ -470,20 +599,58 @@ Examples: help="Erase entire flash before writing (implies --full)") args = parser.parse_args() - baud = args.baud - print("╔══════════════════════════════════════════╗") - print("║ RNodeTHV4 Flash Utility ║") - print("║ Heltec WiFi LoRa 32 V4 Boundary Node ║") - print("╚══════════════════════════════════════════╝") - print() - - # Find esptool + # Find esptool early — needed for both auto-detect and flashing esptool_cmd = find_esptool() if not esptool_cmd: print("Error: esptool not found!") print("Install it with: pip install esptool") sys.exit(1) + + # ── Board detection ───────────────────────────────────────────────── + detected_info = None + _early_port = None + + if args.board: + # Explicit board — no detection needed + _board = args.board + elif args.merge_only: + # No device needed for merge — fall back to default + _board = DEFAULT_BOARD + print(f"(No --board specified; defaulting to {DEFAULT_BOARD} for merge)") + else: + # Auto-detect from connected device + _early_port = args.port or find_serial_port() + if not _early_port: + print("No serial port detected and no --board specified.") + print(f"Defaulting to {DEFAULT_BOARD}. Specify with --board v3 or --board v4.") + _board = DEFAULT_BOARD + else: + print(f"Detecting board on {_early_port}...") + board_key, info = detect_board(_early_port, esptool_cmd) + if board_key: + _board = board_key + detected_info = info + print(f" Chip: {info.get('chip', '?')}") + print(f" Flash: {info.get('flash_size', '?')}") + print(f" Features: {info.get('features', '?')}") + print(f" MAC: {info.get('mac', '?')}") + print(f" → Detected: {BOARD_PROFILES[board_key]['name']}") + else: + reason = info # info is the error reason when board_key is None + print(f" Auto-detect failed: {reason}") + print(f" Defaulting to {DEFAULT_BOARD}. Specify with --board v3 or --board v4.") + _board = DEFAULT_BOARD + + baud = args.baud or BAUD_RATE() + bp = board_profile() + + print() + print("╔══════════════════════════════════════════╗") + print("║ RNodeTHV4 Flash Utility ║") + print(f"║ {bp['name']:^40s} ║") + print("╚══════════════════════════════════════════╝") + print() print(f"Using esptool: {' '.join(esptool_cmd)}") # --erase implies --full (after erase, device needs bootloader + partitions) @@ -492,6 +659,9 @@ Examples: # Determine firmware file firmware_path = None + merged_fn = MERGED_FILENAME() + firmware_bin = FIRMWARE_BIN() + pio_env = PIO_ENV() if args.file: firmware_path = args.file @@ -500,69 +670,69 @@ Examples: sys.exit(1) elif args.download: - firmware_path = MERGED_FILENAME + firmware_path = merged_fn 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}") + if merge_firmware(merged_fn, esptool_cmd): + print(f"\nDone! Flash with: python flash.py --board {_board} --file {merged_fn}") else: sys.exit(1) return elif args.full: # Full flash: use or create merged binary - if os.path.isfile(FIRMWARE_BIN): + if os.path.isfile(firmware_bin): # Build exists — (re-)merge - if os.path.isfile(MERGED_FILENAME): - build_time = os.path.getmtime(FIRMWARE_BIN) - merge_time = os.path.getmtime(MERGED_FILENAME) + if os.path.isfile(merged_fn): + build_time = os.path.getmtime(firmware_bin) + merge_time = os.path.getmtime(merged_fn) if build_time > merge_time: print("Build output is newer than merged binary, re-merging...") - if not merge_firmware(MERGED_FILENAME, esptool_cmd): + if not merge_firmware(merged_fn, esptool_cmd): sys.exit(1) else: print("Creating merged binary from PlatformIO build output...") - if not merge_firmware(MERGED_FILENAME, esptool_cmd): + if not merge_firmware(merged_fn, esptool_cmd): sys.exit(1) - firmware_path = MERGED_FILENAME - elif os.path.isfile(MERGED_FILENAME): - firmware_path = MERGED_FILENAME + firmware_path = merged_fn + elif os.path.isfile(merged_fn): + firmware_path = merged_fn else: print("No firmware found for full flash!") 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 ") + 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 ") sys.exit(1) else: # Default: app-only flash (preserves settings) - if os.path.isfile(FIRMWARE_BIN): - firmware_path = FIRMWARE_BIN + if os.path.isfile(firmware_bin): + firmware_path = firmware_bin print(f"App-only update (preserves WiFi/boundary settings)") print(f" Use --full for a complete flash, or --erase for recovery.") - elif os.path.isfile(MERGED_FILENAME): - firmware_path = MERGED_FILENAME - print(f"No build output found, using merged binary: {MERGED_FILENAME}") + 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(" 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 ") + 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 ") sys.exit(1) - # Flash - port = args.port or find_serial_port() + # Flash — reuse early-detected port if available + port = args.port or _early_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") + print(f"Connect your {bp['name']} via USB and try again,") + print(f"or specify manually: python flash.py --board {_board} --port /dev/ttyACM0") sys.exit(1) print(f"\nSerial port: {port}") diff --git a/lib/microReticulum/src/Log.h b/lib/microReticulum/src/Log.h index 5caacbb..e12c578 100755 --- a/lib/microReticulum/src/Log.h +++ b/lib/microReticulum/src/Log.h @@ -25,10 +25,10 @@ #define VERBOSE(msg) (RNS::verbose(msg)) #define VERBOSEF(msg, ...) (RNS::verbosef(msg, __VA_ARGS__)) #ifndef NDEBUG - #define DEBUG(msg) (RNS::debug(msg)) - #define DEBUGF(msg, ...) (RNS::debugf(msg, __VA_ARGS__)) - #define TRACE(msg) (RNS::trace(msg)) - #define TRACEF(msg, ...) (RNS::tracef(msg, __VA_ARGS__)) + #define DEBUG(msg) do { if (RNS::loglevel() >= RNS::LOG_DEBUG) RNS::debug(msg); } while(0) + #define DEBUGF(msg, ...) do { if (RNS::loglevel() >= RNS::LOG_DEBUG) RNS::debugf(msg, __VA_ARGS__); } while(0) + #define TRACE(msg) do { if (RNS::loglevel() >= RNS::LOG_TRACE) RNS::trace(msg); } while(0) + #define TRACEF(msg, ...) do { if (RNS::loglevel() >= RNS::LOG_TRACE) RNS::tracef(msg, __VA_ARGS__); } while(0) #if defined(RNS_MEM_LOG) #define MEM(msg) (RNS::mem(msg)) #define MEMF(msg, ...) (RNS::memf(msg, __VA_ARGS__)) diff --git a/lib/microReticulum/src/Transport.cpp b/lib/microReticulum/src/Transport.cpp index f9e0db6..bcec600 100755 --- a/lib/microReticulum/src/Transport.cpp +++ b/lib/microReticulum/src/Transport.cpp @@ -104,6 +104,7 @@ using namespace RNS::Utilities; static std::set _boundary_local_addresses; // BOUNDARY MODE Whitelist 2: addresses mentioned in packets from local devices static std::set _boundary_mentioned_addresses; +static const uint16_t _boundary_maxsize = 200; // BOUNDARY MODE: Check if an interface is the backbone static bool is_backbone_interface(const Interface& iface) { @@ -256,6 +257,9 @@ static bool is_backbone_interface(const Interface& iface) { /*static*/ void Transport::jobs() { //TRACE("Transport::jobs()"); + // Heap telemetry: snapshot at jobs entry + size_t _jobs_heap_entry = OS::heap_available(); + std::vector outgoing; std::set path_requests; int count; @@ -443,6 +447,15 @@ static bool is_backbone_interface(const Interface& iface) { _packet_hashlist.erase(_packet_hashlist.begin(), iter); } +#ifdef BOUNDARY_MODE + // Cull the boundary mentioned addresses if it has reached its max size + if (_boundary_mentioned_addresses.size() > _boundary_maxsize) { + std::set::iterator iter = _boundary_mentioned_addresses.begin(); + std::advance(iter, _boundary_mentioned_addresses.size() - _boundary_maxsize); + _boundary_mentioned_addresses.erase(_boundary_mentioned_addresses.begin(), iter); + } +#endif + // Cull the path request tags list if it has reached its max size if (_discovery_pr_tags.size() > _max_pr_tags) { std::set::iterator iter = _discovery_pr_tags.begin(); @@ -656,6 +669,15 @@ static bool is_backbone_interface(const Interface& iface) { _jobs_running = false; + // Heap telemetry: snapshot at jobs exit + { + size_t _jobs_heap_exit = OS::heap_available(); + int _jobs_delta = (int)_jobs_heap_exit - (int)_jobs_heap_entry; + if (_jobs_delta < -64 || _jobs_delta > 64) { + VERBOSEF("[HEAP-TEL] jobs: %d bytes (heap=%u)", _jobs_delta, (uint32_t)_jobs_heap_exit); + } + } + // CBA send announce retransmission packets for (auto& packet : outgoing) { packet.send(); @@ -1201,6 +1223,9 @@ static bool is_backbone_interface(const Interface& iface) { /*static*/ void Transport::inbound(const Bytes& raw, const Interface& interface /*= {Type::NONE}*/) { TRACEF("Transport::inbound: received %d bytes", raw.size()); ++_packets_received; + + // Heap telemetry: snapshot at entry + size_t _heap_at_entry = OS::heap_available(); // CBA if (_callbacks._receive_packet) { try { @@ -1440,7 +1465,16 @@ static bool is_backbone_interface(const Interface& iface) { } #endif - // CBA ACCUMULATES + // Heap telemetry: snapshot after boundary filter + { + size_t _heap_after_boundary = OS::heap_available(); + int _boundary_delta = (int)_heap_after_boundary - (int)_heap_at_entry; + if (_boundary_delta < -64) { + VERBOSEF("[HEAP-TEL] boundary: %d bytes (bma=%u phl=%u)", _boundary_delta, _boundary_mentioned_addresses.size(), _packet_hashlist.size()); + } + } + + // CBA ACCUMULATES _packet_hashlist.insert(packet.packet_hash()); cache_packet(packet); @@ -2642,6 +2676,21 @@ static bool is_backbone_interface(const Interface& iface) { } } + // Heap telemetry: snapshot at exit + { + size_t _heap_at_exit = OS::heap_available(); + int _inbound_delta = (int)_heap_at_exit - (int)_heap_at_entry; + // Log every 100th packet or when delta exceeds threshold + static uint32_t _tel_pkt_count = 0; + ++_tel_pkt_count; + if (_inbound_delta < -64 || (_tel_pkt_count % 100 == 0)) { + VERBOSEF("[HEAP-TEL] inbound: %d bytes (heap=%u pin=%u bma=%u phl=%u lt=%u revr=%u)", + _inbound_delta, (uint32_t)_heap_at_exit, _packets_received, + _boundary_mentioned_addresses.size(), _packet_hashlist.size(), + _link_table.size(), _reverse_table.size()); + } + } + _jobs_locked = false; } @@ -4249,6 +4298,7 @@ TRACE("Transport::write_path_table: buffer size " + std::to_string(Persistence:: interface_announces += interface.announce_queue().size(); } VERBOSEF("phl: %u rcp: %u lt: %u pl: %u al: %u tun: %u", _packet_hashlist.size(), _receipts.size(), _link_table.size(), _pending_links.size(), _active_links.size(), _tunnels.size()); + VERBOSEF("bla: %u bma: %u", _boundary_local_addresses.size(), _boundary_mentioned_addresses.size()); VERBOSEF("pin: %u pout: %u padd: %u dpr: %u ikd: %u ia: %u\r\n", _packets_received, _packets_sent, _destinations_added, destination_path_responses, Identity::_known_destinations.size(), interface_announces); _last_memory = memory; diff --git a/platformio.ini b/platformio.ini index fc6b28e..a3eb7f9 100755 --- a/platformio.ini +++ b/platformio.ini @@ -309,6 +309,37 @@ lib_deps = ${env.lib_deps} XPowersLib@^0.2.1 +[env:heltec_V3_boundary] +platform = espressif32 +board = heltec_wifi_lora_32_V3 +custom_variant = heltec32v3 +board_build.filesystem = littlefs +; Flash / memory layout for 8MB flash + 8MB PSRAM (QSPI) +board_upload.flash_size = 8MB +board_upload.maximum_size = 8388608 +board_build.partitions = default_8MB.csv +board_build.flash_mode = qio +board_build.psram_type = qio +board_build.arduino.memory_type = qio_qspi +monitor_speed = 115200 +build_flags = + ${env.build_flags} + -DBOARD_MODEL=BOARD_HELTEC32_V3 + -DBOARD_HAS_PSRAM=1 + -DBOUNDARY_MODE + ;-DNDEBUG + -DRNS_USE_TLSF=1 + -DRNS_USE_ALLOCATOR=1 + ; --- Boundary mode defaults (override via EEPROM at runtime) --- + ; TCP server mode (0=server, 1=client) + -DBOUNDARY_TCP_MODE=0 + ; TCP listen/connect port + -DBOUNDARY_TCP_PORT=4242 +lib_deps = + ${env.lib_deps} + XPowersLib@^0.2.1 +monitor_filters = esp32_exception_decoder + [env:heltec_wifi_lora_32_V4] platform = espressif32 board = esp32-s3-devkitc-1 @@ -342,7 +373,7 @@ build_flags = -DARDUINO_USB_CDC_ON_BOOT=1 -DBOARD_HAS_PSRAM=1 -DBOUNDARY_MODE - -DNDEBUG + ;-DNDEBUG -DRNS_USE_TLSF=1 -DRNS_USE_ALLOCATOR=1 ; --- Boundary mode defaults (override via EEPROM at runtime) ---