v1.0.5: Heltec V3 support, heap stability fix, TCP reconnection improvements

- Add Heltec WiFi LoRa 32 V3 board support (8MB flash, 8MB PSRAM)
  - New heltec_V3_boundary build environment in platformio.ini
  - Board auto-detection in flash.py (8MB=V3, 16MB=V4)
  - V3 board definition in Boards.h
- Fix heap exhaustion causing watchdog reboots every ~70 min
  - Lower boundary_mentioned_addresses cap from 512 to 200
  - Heap now stable at ~38KB free (was draining to 0)
- TCP reconnection improvements in TcpInterface.h
  - SO_LINGER(0) for clean socket teardown
  - 10-minute read timeout prevents zombie connections
  - Defensive client cleanup on accept
- Add heap telemetry instrumentation (HEAP-TEL) for monitoring
- Add level guards on TRACE/DEBUG macros in Log.h
- Update README for dual V3/V4 board support
This commit is contained in:
James L
2026-02-26 14:32:18 -05:00
parent 990649d810
commit 5e0e3f538a
8 changed files with 424 additions and 111 deletions

View File

@@ -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

View File

@@ -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 48 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

View File

@@ -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);

View File

@@ -17,6 +17,7 @@
#ifdef BOUNDARY_MODE
#include <WiFi.h>
#include <lwip/sockets.h> // SO_LINGER — force RST to free lwIP PCBs immediately
#include <Interface.h>
#include <Transport.h>
#include <Bytes.h>
@@ -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;

294
flash.py
View File

@@ -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 <path>")
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)
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 <path>")
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
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}")

View File

@@ -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__))

View File

@@ -104,6 +104,7 @@ using namespace RNS::Utilities;
static std::set<Bytes> _boundary_local_addresses;
// BOUNDARY MODE Whitelist 2: addresses mentioned in packets from local devices
static std::set<Bytes> _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<Packet> outgoing;
std::set<Bytes> 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<Bytes>::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<Bytes>::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;

View File

@@ -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) ---