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:
6
Boards.h
6
Boards.h
@@ -349,7 +349,11 @@
|
|||||||
#define HAS_DISPLAY true
|
#define HAS_DISPLAY true
|
||||||
#define HAS_WIFI true
|
#define HAS_WIFI true
|
||||||
#define HAS_BLUETOOTH false
|
#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_PMU true
|
||||||
#define HAS_CONSOLE true
|
#define HAS_CONSOLE true
|
||||||
#define HAS_EEPROM true
|
#define HAS_EEPROM true
|
||||||
|
|||||||
50
README.md
50
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
|
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
|
- **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
|
- **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
|
- **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
|
## 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 |
|
| Component | Heltec V3 | Heltec V4 |
|
||||||
|-----------|------|
|
|-----------|-----------|----------|
|
||||||
| **Board** | Heltec WiFi LoRa 32 V4 |
|
| **MCU** | ESP32-S3 | ESP32-S3 |
|
||||||
| **MCU** | ESP32-S3, 2MB PSRAM, 16MB Flash |
|
| **Flash** | 8 MB | 16 MB |
|
||||||
| **Radio** | SX1262 + GC1109 PA (up to 28 dBm) |
|
| **PSRAM** | 8 MB (QSPI) | 2 MB (QSPI) |
|
||||||
| **Display** | SSD1306 OLED 128×64 |
|
| **Radio** | SX1262 | SX1262 + GC1109 PA |
|
||||||
| **WiFi** | 2.4 GHz 802.11 b/g/n |
|
| **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
|
## Quick Start
|
||||||
|
|
||||||
@@ -56,13 +60,18 @@ git clone https://github.com/jrl290/RNodeTHV4.git
|
|||||||
cd RNodeTHV4
|
cd RNodeTHV4
|
||||||
|
|
||||||
# Download latest firmware from GitHub Releases and flash
|
# Download latest firmware from GitHub Releases and flash
|
||||||
|
# (auto-detects V3 vs V4 from flash size)
|
||||||
python flash.py --download
|
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
|
# Or flash a local binary
|
||||||
python flash.py --file rnodethv4_firmware.bin
|
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)
|
### Option B: Build from Source (PlatformIO)
|
||||||
|
|
||||||
@@ -74,15 +83,18 @@ For development or customization:
|
|||||||
git clone https://github.com/jrl290/RNodeTHV4.git
|
git clone https://github.com/jrl290/RNodeTHV4.git
|
||||||
cd RNodeTHV4
|
cd RNodeTHV4
|
||||||
|
|
||||||
# Build
|
# Build for V4
|
||||||
pio run -e heltec_V4_boundary
|
pio run -e heltec_V4_boundary
|
||||||
|
|
||||||
|
# Build for V3
|
||||||
|
pio run -e heltec_V3_boundary
|
||||||
|
|
||||||
# Flash (via PlatformIO)
|
# Flash (via PlatformIO)
|
||||||
pio run -e heltec_V4_boundary -t upload
|
pio run -e heltec_V4_boundary -t upload
|
||||||
|
|
||||||
# Or create a merged binary and flash with the utility
|
# Or create a merged binary and flash with the utility
|
||||||
python flash.py --merge-only # creates rnodethv4_firmware.bin
|
python flash.py --merge-only # creates merged firmware bin
|
||||||
python flash.py # flash it
|
python flash.py # flash it (auto-detects board)
|
||||||
|
|
||||||
# Monitor serial output (optional)
|
# Monitor serial output (optional)
|
||||||
pio device monitor -e heltec_V4_boundary
|
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 |
|
| `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 |
|
| `Display.h` | OLED display layout — boundary-specific status page |
|
||||||
| `flash.py` | Flash utility — list serial ports, download from GitHub, merge & flash firmware |
|
| `flash.py` | Flash utility — list serial ports, download from GitHub, merge & flash firmware |
|
||||||
| `Boards.h` | Board variant definition for `heltec32v4_boundary` |
|
| `Boards.h` | Board variant definitions for V3 and V4 |
|
||||||
| `platformio.ini` | Build targets: `heltec_V4_boundary` and `heltec_V4_boundary-local` |
|
| `platformio.ini` | Build targets: `heltec_V3_boundary`, `heltec_V4_boundary`, and `heltec_V4_boundary-local` |
|
||||||
|
|
||||||
### Library Patches
|
### 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()` |
|
| `Identity.cpp` | `_known_destinations_maxsize` = 24, `cull_known_destinations()` |
|
||||||
| `Type.h` | `MODE_BOUNDARY` = 0x20, reduced `MAX_QUEUED_ANNOUNCES`, `MAX_RECEIPTS`, shorter timeouts |
|
| `Type.h` | `MODE_BOUNDARY` = 0x20, reduced `MAX_QUEUED_ANNOUNCES`, `MAX_RECEIPTS`, shorter timeouts |
|
||||||
|
|
||||||
### Memory Usage (typical)
|
### Memory Usage (typical, V4)
|
||||||
|
|
||||||
| Resource | Used | Available |
|
| Resource | Used | Available |
|
||||||
|----------|------|-----------|
|
|----------|------|----------|
|
||||||
| RAM | ~21.7% | 320 KB |
|
| RAM | ~21.7% | 320 KB |
|
||||||
| Flash | ~18.1% | 16 MB |
|
| Flash | ~18.4% | 16 MB |
|
||||||
| PSRAM | Dynamic | 2 MB |
|
| PSRAM | Dynamic | 2 MB |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -635,7 +635,8 @@ void setup() {
|
|||||||
RNS::Transport::set_transmit_packet_callback(on_transmit_packet);
|
RNS::Transport::set_transmit_packet_callback(on_transmit_packet);
|
||||||
|
|
||||||
Serial.write("Starting RNS...\r\n");
|
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);
|
//RNS::loglevel(RNS::LOG_MEM);
|
||||||
|
|
||||||
HEAD("Registering LoRA Interface...", RNS::LOG_TRACE);
|
HEAD("Registering LoRA Interface...", RNS::LOG_TRACE);
|
||||||
@@ -701,6 +702,9 @@ void setup() {
|
|||||||
0,
|
0,
|
||||||
"LocalTcpInterface"
|
"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 = local_tcp_interface_ptr;
|
||||||
local_tcp_rns_interface.mode(RNS::Type::Interface::MODE_ACCESS_POINT);
|
local_tcp_rns_interface.mode(RNS::Type::Interface::MODE_ACCESS_POINT);
|
||||||
RNS::Transport::register_interface(local_tcp_rns_interface);
|
RNS::Transport::register_interface(local_tcp_rns_interface);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
#ifdef BOUNDARY_MODE
|
#ifdef BOUNDARY_MODE
|
||||||
|
|
||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
|
#include <lwip/sockets.h> // SO_LINGER — force RST to free lwIP PCBs immediately
|
||||||
#include <Interface.h>
|
#include <Interface.h>
|
||||||
#include <Transport.h>
|
#include <Transport.h>
|
||||||
#include <Bytes.h>
|
#include <Bytes.h>
|
||||||
@@ -75,6 +76,7 @@ public:
|
|||||||
_last_reconnect(0),
|
_last_reconnect(0),
|
||||||
_last_keepalive(0),
|
_last_keepalive(0),
|
||||||
_reconnect_interval(TCP_IF_RECONNECT_MIN),
|
_reconnect_interval(TCP_IF_RECONNECT_MIN),
|
||||||
|
_read_timeout(TCP_IF_READ_TIMEOUT),
|
||||||
_resolved_ip((uint32_t)0),
|
_resolved_ip((uint32_t)0),
|
||||||
_consecutive_failures(0),
|
_consecutive_failures(0),
|
||||||
_started(false)
|
_started(false)
|
||||||
@@ -128,7 +130,16 @@ public:
|
|||||||
void stop() {
|
void stop() {
|
||||||
for (int i = 0; i < TCP_IF_MAX_CLIENTS; i++) {
|
for (int i = 0; i < TCP_IF_MAX_CLIENTS; i++) {
|
||||||
if (_clients[i].active) {
|
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.stop();
|
||||||
|
_clients[i].client = WiFiClient();
|
||||||
_clients[i].active = false;
|
_clients[i].active = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,25 +196,15 @@ public:
|
|||||||
if (!_clients[i].active) continue;
|
if (!_clients[i].active) continue;
|
||||||
|
|
||||||
if (!_clients[i].client.connected()) {
|
if (!_clients[i].client.connected()) {
|
||||||
Serial.printf("[TcpIF] Client %d disconnected\r\n", i);
|
_cleanup_client(i, "disconnected");
|
||||||
_clients[i].client.stop();
|
|
||||||
_clients[i].active = false;
|
|
||||||
_clients[i].in_frame = false;
|
|
||||||
_clients[i].escape = false;
|
|
||||||
_clients[i].rxlen = 0;
|
|
||||||
_num_clients--;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check read timeout
|
// Check read timeout (0 = disabled)
|
||||||
if (_clients[i].last_activity > 0 &&
|
if (_read_timeout > 0 &&
|
||||||
(millis() - _clients[i].last_activity) > TCP_IF_READ_TIMEOUT) {
|
_clients[i].last_activity > 0 &&
|
||||||
Serial.printf("[TcpIF] Client %d read timeout\r\n", i);
|
(millis() - _clients[i].last_activity) > _read_timeout) {
|
||||||
_clients[i].client.stop();
|
_cleanup_client(i, "read timeout");
|
||||||
_clients[i].active = false;
|
|
||||||
_clients[i].in_frame = false;
|
|
||||||
_clients[i].rxlen = 0;
|
|
||||||
_num_clients--;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +221,7 @@ public:
|
|||||||
int clientCount() const { return _num_clients; }
|
int clientCount() const { return _num_clients; }
|
||||||
bool isStarted() const { return _started; }
|
bool isStarted() const { return _started; }
|
||||||
bool isConnected() const { return _num_clients > 0; }
|
bool isConnected() const { return _num_clients > 0; }
|
||||||
|
void setReadTimeout(uint32_t timeout_ms) { _read_timeout = timeout_ms; }
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
// ─── RNS InterfaceImpl: outgoing packet from RNS Transport ───────────────
|
// ─── RNS InterfaceImpl: outgoing packet from RNS Transport ───────────────
|
||||||
@@ -248,12 +250,7 @@ protected:
|
|||||||
if (_clients[i].active && _clients[i].client.connected()) {
|
if (_clients[i].active && _clients[i].client.connected()) {
|
||||||
size_t written = _clients[i].client.write(frame_buf, flen);
|
size_t written = _clients[i].client.write(frame_buf, flen);
|
||||||
if (written == 0) {
|
if (written == 0) {
|
||||||
Serial.printf("[TcpIF] Write failed on client %d, dropping\r\n", i);
|
_cleanup_client(i, "write failed");
|
||||||
_clients[i].client.stop();
|
|
||||||
_clients[i].active = false;
|
|
||||||
_clients[i].in_frame = false;
|
|
||||||
_clients[i].rxlen = 0;
|
|
||||||
_num_clients--;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,6 +267,38 @@ protected:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
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 ──────────────────────────────────────────
|
// ─── HDLC byte-level deframing ──────────────────────────────────────────
|
||||||
void _hdlc_deframe(int idx, uint8_t byte) {
|
void _hdlc_deframe(int idx, uint8_t byte) {
|
||||||
TcpClient& c = _clients[idx];
|
TcpClient& c = _clients[idx];
|
||||||
@@ -306,6 +335,18 @@ private:
|
|||||||
// Find a free slot
|
// Find a free slot
|
||||||
for (int i = 0; i < TCP_IF_MAX_CLIENTS; i++) {
|
for (int i = 0; i < TCP_IF_MAX_CLIENTS; i++) {
|
||||||
if (!_clients[i].active) {
|
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 = newClient;
|
||||||
_clients[i].client.setNoDelay(true);
|
_clients[i].client.setNoDelay(true);
|
||||||
_clients[i].client.setTimeout(TCP_IF_WRITE_TIMEOUT / 1000);
|
_clients[i].client.setTimeout(TCP_IF_WRITE_TIMEOUT / 1000);
|
||||||
@@ -399,6 +440,7 @@ private:
|
|||||||
uint32_t _last_reconnect;
|
uint32_t _last_reconnect;
|
||||||
uint32_t _last_keepalive;
|
uint32_t _last_keepalive;
|
||||||
uint32_t _reconnect_interval;
|
uint32_t _reconnect_interval;
|
||||||
|
uint32_t _read_timeout;
|
||||||
IPAddress _resolved_ip;
|
IPAddress _resolved_ip;
|
||||||
uint16_t _consecutive_failures;
|
uint16_t _consecutive_failures;
|
||||||
bool _started;
|
bool _started;
|
||||||
|
|||||||
294
flash.py
294
flash.py
@@ -2,16 +2,19 @@
|
|||||||
"""
|
"""
|
||||||
RNodeTHV4 Flash Utility
|
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.
|
No PlatformIO required — just Python 3 and a USB cable.
|
||||||
|
|
||||||
Default mode flashes only the app partition (0x10000), preserving
|
Default mode flashes only the app partition (0x10000), preserving
|
||||||
bootloader, partition table, NVS, and EEPROM settings.
|
bootloader, partition table, NVS, and EEPROM settings.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
# Update firmware (preserves WiFi/boundary settings)
|
# Update firmware — V4 (default)
|
||||||
python flash.py
|
python flash.py
|
||||||
|
|
||||||
|
# Update firmware — V3
|
||||||
|
python flash.py --board v3
|
||||||
|
|
||||||
# Full flash with merged binary (overwrites everything)
|
# Full flash with merged binary (overwrites everything)
|
||||||
python flash.py --full
|
python flash.py --full
|
||||||
|
|
||||||
@@ -43,9 +46,6 @@ import time
|
|||||||
CHIP = "esp32s3"
|
CHIP = "esp32s3"
|
||||||
FLASH_MODE = "qio"
|
FLASH_MODE = "qio"
|
||||||
FLASH_FREQ = "80m"
|
FLASH_FREQ = "80m"
|
||||||
FLASH_SIZE = "16MB"
|
|
||||||
BAUD_RATE = "921600"
|
|
||||||
MERGED_FILENAME = "rnodethv4_firmware.bin"
|
|
||||||
GITHUB_REPO = "jrl290/RNodeTHV4"
|
GITHUB_REPO = "jrl290/RNodeTHV4"
|
||||||
|
|
||||||
# Flash addresses for ESP32-S3 Arduino framework
|
# Flash addresses for ESP32-S3 Arduino framework
|
||||||
@@ -54,11 +54,61 @@ PARTITIONS_ADDR = 0x8000
|
|||||||
BOOT_APP0_ADDR = 0xe000
|
BOOT_APP0_ADDR = 0xe000
|
||||||
APP_ADDR = 0x10000
|
APP_ADDR = 0x10000
|
||||||
|
|
||||||
# PlatformIO build output paths (relative to project root)
|
# ── Board profiles ─────────────────────────────────────────────────────────────
|
||||||
BUILD_DIR = ".pio/build/heltec_V4_boundary"
|
# Each board defines its PIO env, flash size, baud rate, firmware binary name,
|
||||||
BOOTLOADER_BIN = os.path.join(BUILD_DIR, "bootloader.bin")
|
# and merged binary name.
|
||||||
PARTITIONS_BIN = os.path.join(BUILD_DIR, "partitions.bin")
|
|
||||||
FIRMWARE_BIN = os.path.join(BUILD_DIR, "rnode_firmware_heltec32v4_boundary.bin")
|
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)
|
# ESP32 partition table magic bytes (first two bytes of a partition table entry)
|
||||||
PARTITION_TABLE_MAGIC = b'\xaa\x50'
|
PARTITION_TABLE_MAGIC = b'\xaa\x50'
|
||||||
@@ -94,6 +144,16 @@ def _find_in_platformio_or_release(build_path, release_name):
|
|||||||
|
|
||||||
return None
|
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():
|
def find_boot_app0():
|
||||||
"""Find boot_app0.bin from PlatformIO framework packages.
|
"""Find boot_app0.bin from PlatformIO framework packages.
|
||||||
@@ -126,16 +186,77 @@ def find_boot_app0():
|
|||||||
|
|
||||||
def find_bootloader():
|
def find_bootloader():
|
||||||
"""Find bootloader.bin from PlatformIO build output or Release/ bundle."""
|
"""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():
|
def find_partitions():
|
||||||
"""Find partitions.bin from PlatformIO build output or Release/ bundle."""
|
"""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()
|
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 ────────────────────────────────────────────────────────────────────
|
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def find_esptool():
|
def find_esptool():
|
||||||
@@ -259,16 +380,16 @@ def download_firmware(dest_path):
|
|||||||
# Find the merged firmware asset
|
# Find the merged firmware asset
|
||||||
asset_url = None
|
asset_url = None
|
||||||
for asset in release.get("assets", []):
|
for asset in release.get("assets", []):
|
||||||
if asset["name"] == MERGED_FILENAME:
|
if asset["name"] == MERGED_FILENAME():
|
||||||
asset_url = asset["browser_download_url"]
|
asset_url = asset["browser_download_url"]
|
||||||
break
|
break
|
||||||
|
|
||||||
if not asset_url:
|
if not asset_url:
|
||||||
print(f"Error: '{MERGED_FILENAME}' not found in latest release ({release.get('tag_name', '?')}).")
|
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", [])])
|
print("Available assets:", [a["name"] for a in release.get("assets", [])])
|
||||||
return False
|
return False
|
||||||
|
|
||||||
print(f"Downloading {release['tag_name']} / {MERGED_FILENAME}...")
|
print(f"Downloading {release['tag_name']} / {MERGED_FILENAME()}...")
|
||||||
try:
|
try:
|
||||||
urlretrieve(asset_url, dest_path)
|
urlretrieve(asset_url, dest_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -293,7 +414,7 @@ def _do_merge(output_path, esptool_cmd, bootloader, partitions, boot_app0, firmw
|
|||||||
"merge_bin",
|
"merge_bin",
|
||||||
"--flash_mode", FLASH_MODE,
|
"--flash_mode", FLASH_MODE,
|
||||||
"--flash_freq", FLASH_FREQ,
|
"--flash_freq", FLASH_FREQ,
|
||||||
"--flash_size", FLASH_SIZE,
|
"--flash_size", FLASH_SIZE(),
|
||||||
"-o", output_path,
|
"-o", output_path,
|
||||||
f"0x{BOOTLOADER_ADDR:x}", bootloader,
|
f"0x{BOOTLOADER_ADDR:x}", bootloader,
|
||||||
f"0x{PARTITIONS_ADDR:x}", partitions,
|
f"0x{PARTITIONS_ADDR:x}", partitions,
|
||||||
@@ -321,11 +442,11 @@ def merge_firmware(output_path, esptool_cmd):
|
|||||||
bootloader = find_bootloader()
|
bootloader = find_bootloader()
|
||||||
partitions = find_partitions()
|
partitions = find_partitions()
|
||||||
boot_app0 = BOOT_APP0_BIN
|
boot_app0 = BOOT_APP0_BIN
|
||||||
firmware = FIRMWARE_BIN
|
firmware = FIRMWARE_BIN()
|
||||||
|
|
||||||
missing = []
|
missing = []
|
||||||
if not bootloader: missing.append(("bootloader", BOOTLOADER_BIN))
|
if not bootloader: missing.append(("bootloader", BOOTLOADER_BIN()))
|
||||||
if not partitions: missing.append(("partitions", PARTITIONS_BIN))
|
if not partitions: missing.append(("partitions", PARTITIONS_BIN()))
|
||||||
if not boot_app0: missing.append(("boot_app0", "(not found)"))
|
if not boot_app0: missing.append(("boot_app0", "(not found)"))
|
||||||
if not os.path.isfile(firmware):
|
if not os.path.isfile(firmware):
|
||||||
missing.append(("firmware", firmware))
|
missing.append(("firmware", firmware))
|
||||||
@@ -333,7 +454,7 @@ def merge_firmware(output_path, esptool_cmd):
|
|||||||
if missing:
|
if missing:
|
||||||
for name, path in missing:
|
for name, path in missing:
|
||||||
print(f"Error: {name} not found: {path}")
|
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 False
|
||||||
|
|
||||||
return _do_merge(output_path, esptool_cmd, bootloader, partitions, boot_app0, firmware)
|
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:
|
if missing:
|
||||||
print(f"Cannot auto-merge: missing {', '.join(missing)}")
|
print(f"Cannot auto-merge: missing {', '.join(missing)}")
|
||||||
print("Place them in the Release/ folder alongside flash.py, or")
|
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
|
return None
|
||||||
|
|
||||||
# Create merged binary next to the app binary
|
# Create merged binary next to the app binary
|
||||||
@@ -406,10 +527,13 @@ def reset_to_bootloader(port):
|
|||||||
return True
|
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."""
|
"""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"\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)
|
# Determine if this is a merged binary (flash at 0x0) or app-only (flash at 0x10000)
|
||||||
is_merged = is_merged_binary(firmware_path)
|
is_merged = is_merged_binary(firmware_path)
|
||||||
@@ -431,7 +555,7 @@ def flash_firmware(firmware_path, port, esptool_cmd, baud=BAUD_RATE):
|
|||||||
"-z",
|
"-z",
|
||||||
"--flash_mode", FLASH_MODE,
|
"--flash_mode", FLASH_MODE,
|
||||||
"--flash_freq", FLASH_FREQ,
|
"--flash_freq", FLASH_FREQ,
|
||||||
"--flash_size", FLASH_SIZE,
|
"--flash_size", flash_size,
|
||||||
flash_addr, firmware_path,
|
flash_addr, firmware_path,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -443,12 +567,14 @@ def flash_firmware(firmware_path, port, esptool_cmd, baud=BAUD_RATE):
|
|||||||
# ── Main ───────────────────────────────────────────────────────────────────────
|
# ── Main ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
global _board
|
||||||
parser = argparse.ArgumentParser(
|
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,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog="""
|
epilog="""
|
||||||
Examples:
|
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 --full # Full flash with merged binary
|
||||||
python flash.py --download # Download latest release and flash
|
python flash.py --download # Download latest release and flash
|
||||||
python flash.py --file firmware.bin # Flash a specific file
|
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
|
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("--file", "-f", help="Path to firmware binary to flash")
|
||||||
parser.add_argument("--port", "-p", help="Serial port (auto-detected if omitted)")
|
parser.add_argument("--port", "-p", help="Serial port (auto-detected if omitted)")
|
||||||
parser.add_argument("--baud", "-b", default=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",
|
parser.add_argument("--download", "-d", action="store_true",
|
||||||
help="Download latest firmware from GitHub Releases")
|
help="Download latest firmware from GitHub Releases")
|
||||||
parser.add_argument("--merge-only", action="store_true",
|
parser.add_argument("--merge-only", action="store_true",
|
||||||
@@ -470,20 +599,58 @@ Examples:
|
|||||||
help="Erase entire flash before writing (implies --full)")
|
help="Erase entire flash before writing (implies --full)")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
baud = args.baud
|
|
||||||
|
|
||||||
print("╔══════════════════════════════════════════╗")
|
# Find esptool early — needed for both auto-detect and flashing
|
||||||
print("║ RNodeTHV4 Flash Utility ║")
|
|
||||||
print("║ Heltec WiFi LoRa 32 V4 Boundary Node ║")
|
|
||||||
print("╚══════════════════════════════════════════╝")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Find esptool
|
|
||||||
esptool_cmd = find_esptool()
|
esptool_cmd = find_esptool()
|
||||||
if not esptool_cmd:
|
if not esptool_cmd:
|
||||||
print("Error: esptool not found!")
|
print("Error: esptool not found!")
|
||||||
print("Install it with: pip install esptool")
|
print("Install it with: pip install esptool")
|
||||||
sys.exit(1)
|
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)}")
|
print(f"Using esptool: {' '.join(esptool_cmd)}")
|
||||||
|
|
||||||
# --erase implies --full (after erase, device needs bootloader + partitions)
|
# --erase implies --full (after erase, device needs bootloader + partitions)
|
||||||
@@ -492,6 +659,9 @@ Examples:
|
|||||||
|
|
||||||
# Determine firmware file
|
# Determine firmware file
|
||||||
firmware_path = None
|
firmware_path = None
|
||||||
|
merged_fn = MERGED_FILENAME()
|
||||||
|
firmware_bin = FIRMWARE_BIN()
|
||||||
|
pio_env = PIO_ENV()
|
||||||
|
|
||||||
if args.file:
|
if args.file:
|
||||||
firmware_path = args.file
|
firmware_path = args.file
|
||||||
@@ -500,69 +670,69 @@ Examples:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
elif args.download:
|
elif args.download:
|
||||||
firmware_path = MERGED_FILENAME
|
firmware_path = merged_fn
|
||||||
if not download_firmware(firmware_path):
|
if not download_firmware(firmware_path):
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
elif args.merge_only:
|
elif args.merge_only:
|
||||||
if merge_firmware(MERGED_FILENAME, esptool_cmd):
|
if merge_firmware(merged_fn, esptool_cmd):
|
||||||
print(f"\nDone! Flash with: python flash.py --file {MERGED_FILENAME}")
|
print(f"\nDone! Flash with: python flash.py --board {_board} --file {merged_fn}")
|
||||||
else:
|
else:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
return
|
return
|
||||||
|
|
||||||
elif args.full:
|
elif args.full:
|
||||||
# Full flash: use or create merged binary
|
# Full flash: use or create merged binary
|
||||||
if os.path.isfile(FIRMWARE_BIN):
|
if os.path.isfile(firmware_bin):
|
||||||
# Build exists — (re-)merge
|
# Build exists — (re-)merge
|
||||||
if os.path.isfile(MERGED_FILENAME):
|
if os.path.isfile(merged_fn):
|
||||||
build_time = os.path.getmtime(FIRMWARE_BIN)
|
build_time = os.path.getmtime(firmware_bin)
|
||||||
merge_time = os.path.getmtime(MERGED_FILENAME)
|
merge_time = os.path.getmtime(merged_fn)
|
||||||
if build_time > merge_time:
|
if build_time > merge_time:
|
||||||
print("Build output is newer than merged binary, re-merging...")
|
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)
|
sys.exit(1)
|
||||||
else:
|
else:
|
||||||
print("Creating merged binary from PlatformIO build output...")
|
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)
|
sys.exit(1)
|
||||||
firmware_path = MERGED_FILENAME
|
firmware_path = merged_fn
|
||||||
elif os.path.isfile(MERGED_FILENAME):
|
elif os.path.isfile(merged_fn):
|
||||||
firmware_path = MERGED_FILENAME
|
firmware_path = merged_fn
|
||||||
else:
|
else:
|
||||||
print("No firmware found for full flash!")
|
print("No firmware found for full flash!")
|
||||||
print()
|
print()
|
||||||
print("Options:")
|
print("Options:")
|
||||||
print(" 1. Build with PlatformIO first: pio run -e heltec_V4_boundary")
|
print(f" 1. Build with PlatformIO first: pio run -e {pio_env}")
|
||||||
print(" 2. Download from GitHub: python flash.py --download")
|
print(f" 2. Download from GitHub: python flash.py --board {_board} --download")
|
||||||
print(" 3. Specify a file: python flash.py --file <path>")
|
print(f" 3. Specify a file: python flash.py --board {_board} --file <path>")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Default: app-only flash (preserves settings)
|
# Default: app-only flash (preserves settings)
|
||||||
if os.path.isfile(FIRMWARE_BIN):
|
if os.path.isfile(firmware_bin):
|
||||||
firmware_path = FIRMWARE_BIN
|
firmware_path = firmware_bin
|
||||||
print(f"App-only update (preserves WiFi/boundary settings)")
|
print(f"App-only update (preserves WiFi/boundary settings)")
|
||||||
print(f" Use --full for a complete flash, or --erase for recovery.")
|
print(f" Use --full for a complete flash, or --erase for recovery.")
|
||||||
elif os.path.isfile(MERGED_FILENAME):
|
elif os.path.isfile(merged_fn):
|
||||||
firmware_path = MERGED_FILENAME
|
firmware_path = merged_fn
|
||||||
print(f"No build output found, using merged binary: {MERGED_FILENAME}")
|
print(f"No build output found, using merged binary: {merged_fn}")
|
||||||
print(f" Note: merged binary will overwrite bootloader + partitions.")
|
print(f" Note: merged binary will overwrite bootloader + partitions.")
|
||||||
else:
|
else:
|
||||||
print("No firmware found!")
|
print("No firmware found!")
|
||||||
print()
|
print()
|
||||||
print("Options:")
|
print("Options:")
|
||||||
print(" 1. Build with PlatformIO first: pio run -e heltec_V4_boundary")
|
print(f" 1. Build with PlatformIO first: pio run -e {pio_env}")
|
||||||
print(" 2. Download from GitHub: python flash.py --download")
|
print(f" 2. Download from GitHub: python flash.py --board {_board} --download")
|
||||||
print(" 3. Specify a file: python flash.py --file <path>")
|
print(f" 3. Specify a file: python flash.py --board {_board} --file <path>")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Flash
|
# Flash — reuse early-detected port if available
|
||||||
port = args.port or find_serial_port()
|
port = args.port or _early_port or find_serial_port()
|
||||||
if not port:
|
if not port:
|
||||||
print("\nError: No serial port detected!")
|
print("\nError: No serial port detected!")
|
||||||
print("Connect your Heltec V4 via USB and try again,")
|
print(f"Connect your {bp['name']} via USB and try again,")
|
||||||
print("or specify manually: python flash.py --port /dev/ttyACM0")
|
print(f"or specify manually: python flash.py --board {_board} --port /dev/ttyACM0")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print(f"\nSerial port: {port}")
|
print(f"\nSerial port: {port}")
|
||||||
|
|||||||
@@ -25,10 +25,10 @@
|
|||||||
#define VERBOSE(msg) (RNS::verbose(msg))
|
#define VERBOSE(msg) (RNS::verbose(msg))
|
||||||
#define VERBOSEF(msg, ...) (RNS::verbosef(msg, __VA_ARGS__))
|
#define VERBOSEF(msg, ...) (RNS::verbosef(msg, __VA_ARGS__))
|
||||||
#ifndef NDEBUG
|
#ifndef NDEBUG
|
||||||
#define DEBUG(msg) (RNS::debug(msg))
|
#define DEBUG(msg) do { if (RNS::loglevel() >= RNS::LOG_DEBUG) RNS::debug(msg); } while(0)
|
||||||
#define DEBUGF(msg, ...) (RNS::debugf(msg, __VA_ARGS__))
|
#define DEBUGF(msg, ...) do { if (RNS::loglevel() >= RNS::LOG_DEBUG) RNS::debugf(msg, __VA_ARGS__); } while(0)
|
||||||
#define TRACE(msg) (RNS::trace(msg))
|
#define TRACE(msg) do { if (RNS::loglevel() >= RNS::LOG_TRACE) RNS::trace(msg); } while(0)
|
||||||
#define TRACEF(msg, ...) (RNS::tracef(msg, __VA_ARGS__))
|
#define TRACEF(msg, ...) do { if (RNS::loglevel() >= RNS::LOG_TRACE) RNS::tracef(msg, __VA_ARGS__); } while(0)
|
||||||
#if defined(RNS_MEM_LOG)
|
#if defined(RNS_MEM_LOG)
|
||||||
#define MEM(msg) (RNS::mem(msg))
|
#define MEM(msg) (RNS::mem(msg))
|
||||||
#define MEMF(msg, ...) (RNS::memf(msg, __VA_ARGS__))
|
#define MEMF(msg, ...) (RNS::memf(msg, __VA_ARGS__))
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ using namespace RNS::Utilities;
|
|||||||
static std::set<Bytes> _boundary_local_addresses;
|
static std::set<Bytes> _boundary_local_addresses;
|
||||||
// BOUNDARY MODE Whitelist 2: addresses mentioned in packets from local devices
|
// BOUNDARY MODE Whitelist 2: addresses mentioned in packets from local devices
|
||||||
static std::set<Bytes> _boundary_mentioned_addresses;
|
static std::set<Bytes> _boundary_mentioned_addresses;
|
||||||
|
static const uint16_t _boundary_maxsize = 200;
|
||||||
|
|
||||||
// BOUNDARY MODE: Check if an interface is the backbone
|
// BOUNDARY MODE: Check if an interface is the backbone
|
||||||
static bool is_backbone_interface(const Interface& iface) {
|
static bool is_backbone_interface(const Interface& iface) {
|
||||||
@@ -256,6 +257,9 @@ static bool is_backbone_interface(const Interface& iface) {
|
|||||||
/*static*/ void Transport::jobs() {
|
/*static*/ void Transport::jobs() {
|
||||||
//TRACE("Transport::jobs()");
|
//TRACE("Transport::jobs()");
|
||||||
|
|
||||||
|
// Heap telemetry: snapshot at jobs entry
|
||||||
|
size_t _jobs_heap_entry = OS::heap_available();
|
||||||
|
|
||||||
std::vector<Packet> outgoing;
|
std::vector<Packet> outgoing;
|
||||||
std::set<Bytes> path_requests;
|
std::set<Bytes> path_requests;
|
||||||
int count;
|
int count;
|
||||||
@@ -443,6 +447,15 @@ static bool is_backbone_interface(const Interface& iface) {
|
|||||||
_packet_hashlist.erase(_packet_hashlist.begin(), iter);
|
_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
|
// Cull the path request tags list if it has reached its max size
|
||||||
if (_discovery_pr_tags.size() > _max_pr_tags) {
|
if (_discovery_pr_tags.size() > _max_pr_tags) {
|
||||||
std::set<Bytes>::iterator iter = _discovery_pr_tags.begin();
|
std::set<Bytes>::iterator iter = _discovery_pr_tags.begin();
|
||||||
@@ -656,6 +669,15 @@ static bool is_backbone_interface(const Interface& iface) {
|
|||||||
|
|
||||||
_jobs_running = false;
|
_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
|
// CBA send announce retransmission packets
|
||||||
for (auto& packet : outgoing) {
|
for (auto& packet : outgoing) {
|
||||||
packet.send();
|
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}*/) {
|
/*static*/ void Transport::inbound(const Bytes& raw, const Interface& interface /*= {Type::NONE}*/) {
|
||||||
TRACEF("Transport::inbound: received %d bytes", raw.size());
|
TRACEF("Transport::inbound: received %d bytes", raw.size());
|
||||||
++_packets_received;
|
++_packets_received;
|
||||||
|
|
||||||
|
// Heap telemetry: snapshot at entry
|
||||||
|
size_t _heap_at_entry = OS::heap_available();
|
||||||
// CBA
|
// CBA
|
||||||
if (_callbacks._receive_packet) {
|
if (_callbacks._receive_packet) {
|
||||||
try {
|
try {
|
||||||
@@ -1440,7 +1465,16 @@ static bool is_backbone_interface(const Interface& iface) {
|
|||||||
}
|
}
|
||||||
#endif
|
#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());
|
_packet_hashlist.insert(packet.packet_hash());
|
||||||
cache_packet(packet);
|
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;
|
_jobs_locked = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4249,6 +4298,7 @@ TRACE("Transport::write_path_table: buffer size " + std::to_string(Persistence::
|
|||||||
interface_announces += interface.announce_queue().size();
|
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("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);
|
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;
|
_last_memory = memory;
|
||||||
|
|||||||
@@ -309,6 +309,37 @@ lib_deps =
|
|||||||
${env.lib_deps}
|
${env.lib_deps}
|
||||||
XPowersLib@^0.2.1
|
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]
|
[env:heltec_wifi_lora_32_V4]
|
||||||
platform = espressif32
|
platform = espressif32
|
||||||
board = esp32-s3-devkitc-1
|
board = esp32-s3-devkitc-1
|
||||||
@@ -342,7 +373,7 @@ build_flags =
|
|||||||
-DARDUINO_USB_CDC_ON_BOOT=1
|
-DARDUINO_USB_CDC_ON_BOOT=1
|
||||||
-DBOARD_HAS_PSRAM=1
|
-DBOARD_HAS_PSRAM=1
|
||||||
-DBOUNDARY_MODE
|
-DBOUNDARY_MODE
|
||||||
-DNDEBUG
|
;-DNDEBUG
|
||||||
-DRNS_USE_TLSF=1
|
-DRNS_USE_TLSF=1
|
||||||
-DRNS_USE_ALLOCATOR=1
|
-DRNS_USE_ALLOCATOR=1
|
||||||
; --- Boundary mode defaults (override via EEPROM at runtime) ---
|
; --- Boundary mode defaults (override via EEPROM at runtime) ---
|
||||||
|
|||||||
Reference in New Issue
Block a user