diff --git a/.commitmsg b/.commitmsg
new file mode 100644
index 0000000..e53ef56
--- /dev/null
+++ b/.commitmsg
@@ -0,0 +1,17 @@
+Fix V3 bootloop: DIO flash mode, auto-verify, boot monitoring
+
+- V3 board profile defaults to DIO flash mode (QIO fails on some flash chips)
+- flash.py: auto-verify on --erase/--full, post-flash boot monitoring with
+ auto-DIO retry on bootloop detection
+- flash.py: erase keeps device in download mode (--after no_reset) to prevent
+ race condition on re-entry
+- flash.py: remove --dio/--verify flags (now automatic), hidden --flash-mode
+ for power users
+- RNode_Firmware.ino: release BT memory (~70KB) on V3 boundary where BT is
+ compile-time disabled
+- RNode_Firmware.ino: add WDT resets throughout setup() to prevent timeout
+ during long init sequences
+- RNode_Firmware.ino: fix while-not-Serial blocking on V3 (no USB-CDC)
+- RNode_Firmware.ino: init bt_devname from WiFi MAC when BT disabled
+- RNode_Firmware.ino: bootloop detection via RTC_NOINIT_ATTR -- forces config
+ portal after 5 rapid reboots
diff --git a/BoundaryConfig.h b/BoundaryConfig.h
index 7e9809d..de9c203 100644
--- a/BoundaryConfig.h
+++ b/BoundaryConfig.h
@@ -39,20 +39,25 @@ bool boundary_needs_config();
// ─── Common bandwidth values (Hz) ───────────────────────────────────────────
// These match Reticulum standard channel plans
-struct BwOption { uint32_t hz; const char* label; };
-static const BwOption BW_OPTIONS[] = {
- { 7800, "7.8 kHz" },
- { 10400, "10.4 kHz" },
- { 15600, "15.6 kHz" },
- { 20800, "20.8 kHz" },
- { 31250, "31.25 kHz" },
- { 41700, "41.7 kHz" },
- { 62500, "62.5 kHz" },
- {125000, "125 kHz" },
- {250000, "250 kHz" },
- {500000, "500 kHz" },
+// Stored in flash (PROGMEM) to save ~200 bytes of RAM
+static const uint32_t BW_OPTIONS_HZ[] PROGMEM = {
+ 7800, 10400, 15600, 20800, 31250, 41700, 62500, 125000, 250000, 500000,
};
-static const int BW_OPTIONS_COUNT = sizeof(BW_OPTIONS) / sizeof(BW_OPTIONS[0]);
+static const char BW_LABEL_0[] PROGMEM = "7.8 kHz";
+static const char BW_LABEL_1[] PROGMEM = "10.4 kHz";
+static const char BW_LABEL_2[] PROGMEM = "15.6 kHz";
+static const char BW_LABEL_3[] PROGMEM = "20.8 kHz";
+static const char BW_LABEL_4[] PROGMEM = "31.25 kHz";
+static const char BW_LABEL_5[] PROGMEM = "41.7 kHz";
+static const char BW_LABEL_6[] PROGMEM = "62.5 kHz";
+static const char BW_LABEL_7[] PROGMEM = "125 kHz";
+static const char BW_LABEL_8[] PROGMEM = "250 kHz";
+static const char BW_LABEL_9[] PROGMEM = "500 kHz";
+static const char* const BW_OPTIONS_LABELS[] PROGMEM = {
+ BW_LABEL_0, BW_LABEL_1, BW_LABEL_2, BW_LABEL_3, BW_LABEL_4,
+ BW_LABEL_5, BW_LABEL_6, BW_LABEL_7, BW_LABEL_8, BW_LABEL_9,
+};
+static const int BW_OPTIONS_COUNT = sizeof(BW_OPTIONS_HZ) / sizeof(BW_OPTIONS_HZ[0]);
// ─── HTML Page Generation ────────────────────────────────────────────────────
@@ -202,12 +207,16 @@ static void config_send_html() {
// Bandwidth — dropdown
html += F("");
diff --git a/Config.h b/Config.h
index 1c8f622..61a3562 100755
--- a/Config.h
+++ b/Config.h
@@ -186,7 +186,8 @@
#define AIRTIME_LONGTERM_MS (AIRTIME_LONGTERM*1000)
#define AIRTIME_BINLEN_MS (STATUS_INTERVAL_MS*DCD_SAMPLES)
#define AIRTIME_BINS ((AIRTIME_LONGTERM*1000)/AIRTIME_BINLEN_MS)
- bool util_samples[DCD_SAMPLES];
+ #define DCD_BITFIELD_SIZE ((DCD_SAMPLES + 7) / 8)
+ uint8_t util_samples[DCD_BITFIELD_SIZE];
uint16_t airtime_bins[AIRTIME_BINS];
float longterm_bins[AIRTIME_BINS];
int dcd_sample = 0;
diff --git a/README.md b/README.md
index 02534f6..e459a06 100755
--- a/README.md
+++ b/README.md
@@ -28,17 +28,17 @@ 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
+- **Dual board support** — supports both Heltec V3 (8MB flash) and V4 (16MB flash, 2MB PSRAM) with automatic board and PSRAM detection
## Hardware
-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.
+Both the **Heltec WiFi LoRa 32 V3** and **V4** are supported. These boards were chosen for their ample flash and LoRa capabilities. PSRAM availability varies — the V4 ships with 2 MB PSRAM, while the V3 uses the ESP32-S3FN8 which has **no PSRAM**. The firmware **detects PSRAM at runtime** and allocates the TLSF memory pool from SPIRAM when available, falling back to internal SRAM (~170 KB) on boards without PSRAM.
| Component | Heltec V3 | Heltec V4 |
|-----------|-----------|----------|
-| **MCU** | ESP32-S3 | ESP32-S3 |
+| **MCU** | ESP32-S3 (ESP32-S3FN8) | ESP32-S3 (ESP32-S3FH4R2) |
| **Flash** | 8 MB | 16 MB |
-| **PSRAM** | 8 MB (QSPI) | 2 MB (QSPI) |
+| **PSRAM** | None | 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 |
diff --git a/RNode_Firmware.ino b/RNode_Firmware.ino
index 537c134..fcb3576 100755
--- a/RNode_Firmware.ino
+++ b/RNode_Firmware.ino
@@ -243,6 +243,16 @@ TcpInterface* local_tcp_interface_ptr = nullptr;
// RTC memory flag — survives software reset but not power cycle
RTC_NOINIT_ATTR uint32_t boundary_config_request;
#define BOUNDARY_CONFIG_MAGIC 0xC0F19A7E
+
+// Bootloop detection: count rapid reboots in RTC memory.
+// After BOOTLOOP_THRESHOLD consecutive reboots within BOOTLOOP_WINDOW_MS,
+// force entry into the config portal so the user can fix settings.
+#define BOOTLOOP_THRESHOLD 5
+#define BOOTLOOP_WINDOW_MS 120000 // 2 minutes
+#define BOOTLOOP_MAGIC 0xB007100D
+RTC_NOINIT_ATTR uint32_t bootloop_magic;
+RTC_NOINIT_ATTR uint32_t bootloop_count;
+RTC_NOINIT_ATTR uint32_t bootloop_first_boot_ms;
#endif
#endif // HAS_RNS
@@ -340,11 +350,11 @@ void setup() {
boot_seq();
#endif
- #if BOARD_MODEL != BOARD_RAK4631 && BOARD_MODEL != BOARD_HELTEC_T114 && BOARD_MODEL != BOARD_TECHO && BOARD_MODEL != BOARD_T3S3 && BOARD_MODEL != BOARD_TBEAM_S_V1 && BOARD_MODEL != BOARD_HELTEC32_V4
+ #if BOARD_MODEL != BOARD_RAK4631 && BOARD_MODEL != BOARD_HELTEC_T114 && BOARD_MODEL != BOARD_TECHO && BOARD_MODEL != BOARD_T3S3 && BOARD_MODEL != BOARD_TBEAM_S_V1 && BOARD_MODEL != BOARD_HELTEC32_V4 && BOARD_MODEL != BOARD_HELTEC32_V3
// Some boards need to wait until the hardware UART is set up before booting
- // the full firmware. In the case of the RAK4631 and Heltec T114, the line below will wait
- // until a serial connection is actually established with a master. Thus, it
- // is disabled on this platform.
+ // the full firmware. In the case of the RAK4631, Heltec T114, and Heltec V3,
+ // the line below will wait until a serial connection is actually established
+ // with a master. Thus, it is disabled on these platforms.
while (!Serial);
#endif
@@ -475,13 +485,41 @@ void setup() {
// Load boundary config so the portal can show current/default values
boundary_load_config();
- // Enter config mode if: first boot with no config, OR button-triggered reboot
+ // ── Bootloop detection ───────────────────────────────────────────────
+ // Track rapid reboots in RTC memory. If the device reboots more than
+ // BOOTLOOP_THRESHOLD times within BOOTLOOP_WINDOW_MS, force the config
+ // portal so the user can fix bad settings.
+ bool bootloop_detected = false;
+ {
+ uint32_t now = millis();
+ if (bootloop_magic != BOOTLOOP_MAGIC) {
+ // First boot or power cycle — initialize counter
+ bootloop_magic = BOOTLOOP_MAGIC;
+ bootloop_count = 1;
+ bootloop_first_boot_ms = now;
+ } else {
+ bootloop_count++;
+ // Check if we're within the time window
+ if (bootloop_count >= BOOTLOOP_THRESHOLD) {
+ Serial.printf("[Boundary] BOOTLOOP DETECTED: %lu reboots — forcing config portal\r\n", bootloop_count);
+ bootloop_detected = true;
+ // Reset counter so next reboot after config portal doesn't re-trigger
+ bootloop_count = 0;
+ bootloop_magic = 0;
+ }
+ }
+ }
+
+ // Enter config mode if: first boot with no config, OR button-triggered reboot,
+ // OR bootloop detected
bool need_config = boundary_needs_config();
bool config_requested = (boundary_config_request == BOUNDARY_CONFIG_MAGIC);
boundary_config_request = 0; // Clear flag immediately
- if (need_config || config_requested) {
- if (config_requested) {
+ if (need_config || config_requested || bootloop_detected) {
+ if (bootloop_detected) {
+ Serial.println("[Boundary] Entering config portal due to bootloop recovery");
+ } else if (config_requested) {
Serial.println("[Boundary] Config mode requested via button hold");
} else {
Serial.println("[Boundary] No configuration found — starting config portal");
@@ -515,6 +553,23 @@ void setup() {
btStop();
esp_bt_controller_mem_release(ESP_BT_MODE_BTDM);
#endif
+ #else
+ #ifdef BOUNDARY_MODE
+ // Even when BLE/BT are compile-time disabled (e.g. V3 boundary),
+ // the ESP32 BT controller is still loaded. Release its ~70KB of RAM.
+ btStop();
+ esp_bt_controller_mem_release(ESP_BT_MODE_BTDM);
+ Serial.write("[Boundary] Released BT controller memory\r\n");
+ #endif
+ #endif
+
+ #ifdef BOUNDARY_MODE
+ // Initialize bt_devname for WiFi hostname when BT is disabled
+ if (!bt_init_ran) {
+ uint8_t mac[6];
+ esp_read_mac(mac, ESP_MAC_WIFI_STA);
+ sprintf(bt_devname, "RNode %02X%02X", mac[4], mac[5]);
+ }
#endif
if (console_active) {
@@ -548,6 +603,11 @@ void setup() {
#endif
#endif
+ // Feed WDT before validation + radio start, which may take time
+ #if MCU_VARIANT == MCU_ESP32
+ esp_task_wdt_reset();
+ #endif
+
// Validate board health, EEPROM and config
validate_status();
@@ -584,6 +644,11 @@ void setup() {
#ifdef HAS_RNS
try {
+ // Feed WDT before filesystem init (may format on first boot)
+ #if MCU_VARIANT == MCU_ESP32
+ esp_task_wdt_reset();
+ #endif
+
// CBA Init filesystem
#if defined(RNS_USE_FS)
filesystem = new FileSystem();
@@ -593,6 +658,11 @@ void setup() {
((FileSystem*)filesystem.get())->init();
#endif
+ // Feed WDT after filesystem init
+ #if MCU_VARIANT == MCU_ESP32
+ esp_task_wdt_reset();
+ #endif
+
HEAD("Registering filesystem...", RNS::LOG_TRACE);
RNS::Utilities::OS::register_filesystem(filesystem);
@@ -630,6 +700,11 @@ void setup() {
// CBA Start RNS
if (hw_ready) {
+ // Feed WDT before RNS startup (identity generation + crypto can be slow)
+ #if MCU_VARIANT == MCU_ESP32
+ esp_task_wdt_reset();
+ #endif
+
RNS::setLogCallback(&on_log);
RNS::Transport::set_receive_packet_callback(on_receive_packet);
RNS::Transport::set_transmit_packet_callback(on_transmit_packet);
@@ -724,6 +799,11 @@ void setup() {
}
#endif
+ // Feed WDT before Reticulum instance creation (loads caches, generates keys)
+ #if MCU_VARIANT == MCU_ESP32
+ esp_task_wdt_reset();
+ #endif
+
HEAD("Creating Reticulum instance...", RNS::LOG_TRACE);
reticulum = RNS::Reticulum();
#ifdef BOUNDARY_MODE
@@ -1987,12 +2067,17 @@ void check_modem_status() {
update_noise_floor();
#if MCU_VARIANT == MCU_ESP32 || MCU_VARIANT == MCU_NRF52
- util_samples[dcd_sample] = dcd;
+ if (dcd) {
+ util_samples[dcd_sample >> 3] |= (1 << (dcd_sample & 7));
+ } else {
+ util_samples[dcd_sample >> 3] &= ~(1 << (dcd_sample & 7));
+ }
dcd_sample = (dcd_sample+1)%DCD_SAMPLES;
if (dcd_sample % UTIL_UPDATE_INTERVAL == 0) {
int util_count = 0;
- for (int ui = 0; ui < DCD_SAMPLES; ui++) {
- if (util_samples[ui]) util_count++;
+ for (int ui = 0; ui < DCD_BITFIELD_SIZE; ui++) {
+ uint8_t b = util_samples[ui];
+ while (b) { util_count += (b & 1); b >>= 1; }
}
local_channel_util = (float)util_count / (float)DCD_SAMPLES;
total_channel_util = local_channel_util + airtime;
@@ -2239,6 +2324,13 @@ void loop() {
}
#ifdef BOUNDARY_MODE
+ // ── Clear bootloop counter once we reach a stable loop iteration ──────────
+ if (bootloop_magic == BOOTLOOP_MAGIC) {
+ bootloop_magic = 0;
+ bootloop_count = 0;
+ Serial.println("[Boundary] Boot stable — bootloop counter cleared");
+ }
+
// ── Heap + WiFi watchdog ───────────────────────────────────────────────────
// Monitor heap and WiFi health. Auto-reboot on critical conditions:
// 1) Internal heap drops below 20KB (WiFi needs ~16KB for RX buffers)
diff --git a/Utilities.h b/Utilities.h
index e4547d1..2563d46 100755
--- a/Utilities.h
+++ b/Utilities.h
@@ -1887,7 +1887,7 @@ void unlock_rom() {
void init_channel_stats() {
#if MCU_VARIANT == MCU_ESP32
- for (uint16_t ai = 0; ai < DCD_SAMPLES; ai++) { util_samples[ai] = false; }
+ memset(util_samples, 0, DCD_BITFIELD_SIZE);
for (uint16_t ai = 0; ai < AIRTIME_BINS; ai++) { airtime_bins[ai] = 0; }
for (uint16_t ai = 0; ai < AIRTIME_BINS; ai++) { longterm_bins[ai] = 0.0; }
local_channel_util = 0.0;
diff --git a/flash.py b/flash.py
index d672fa8..8af6edc 100755
--- a/flash.py
+++ b/flash.py
@@ -49,10 +49,13 @@ import time
VERSION = "1.0.17"
CHIP = "esp32s3"
-FLASH_MODE = "qio"
+FLASH_MODE = "qio" # Global default; overridden by board profile
FLASH_FREQ = "80m"
GITHUB_REPO = "jrl290/RNodeTHV4"
+# Runtime state (set automatically during main())
+_flash_mode_override = None # CLI --flash-mode sets this; otherwise board profile wins
+
# Flash addresses for ESP32-S3 Arduino framework
BOOTLOADER_ADDR = 0x0000
PARTITIONS_ADDR = 0x8000
@@ -72,6 +75,7 @@ BOARD_PROFILES = {
"merged_filename": "rnodethv4_firmware.bin",
"flash_size": "16MB",
"baud_rate": "921600",
+ "flash_mode": "qio", # V4 flash chips support QIO reliably
},
"v3": {
"name": "Heltec WiFi LoRa 32 V3",
@@ -81,6 +85,7 @@ BOARD_PROFILES = {
"merged_filename": "rnodethv3_firmware.bin",
"flash_size": "8MB",
"baud_rate": "460800",
+ "flash_mode": "dio", # V3 uses DIO — some flash chips do not support QIO
},
}
DEFAULT_BOARD = "v4"
@@ -109,6 +114,13 @@ def FLASH_SIZE():
def BAUD_RATE():
return board_profile()["baud_rate"]
+def BOARD_FLASH_MODE():
+ """Return the effective flash mode for the current board.
+
+ Priority: CLI override > board profile > global default.
+ """
+ return _flash_mode_override or board_profile().get("flash_mode", FLASH_MODE)
+
def MERGED_FILENAME():
return board_profile()["merged_filename"]
@@ -563,10 +575,12 @@ def _do_merge(output_path, esptool_cmd, bootloader, partitions, boot_app0, firmw
print(f" boot_app0: {boot_app0} @ 0x{BOOT_APP0_ADDR:04x}")
print(f" Firmware: {firmware} @ 0x{APP_ADDR:05x}")
+ flash_mode = BOARD_FLASH_MODE()
+ print(f" Flash mode: {flash_mode.upper()}")
cmd = esptool_cmd + [
"--chip", CHIP,
"merge_bin",
- "--flash_mode", FLASH_MODE,
+ "--flash_mode", flash_mode,
"--flash_freq", FLASH_FREQ,
"--flash_size", FLASH_SIZE(),
"-o", output_path,
@@ -800,13 +814,24 @@ def reset_to_bootloader(port):
return True
-def flash_firmware(firmware_path, port, esptool_cmd, baud=None):
- """Flash firmware to the device."""
+def flash_firmware(firmware_path, port, esptool_cmd, baud=None,
+ no_reset_before=False, verify=False,
+ flash_mode=None, no_hard_reset=False):
+ """Flash firmware to the device.
+
+ Args:
+ no_reset_before: If True, use ``--before no_reset`` so we don't try to
+ re-enter download mode (device is already in stub after erase).
+ verify: If True, add ``--verify`` for read-back verification.
+ flash_mode: Override flash mode (default: board profile).
+ no_hard_reset: If True, use ``--after no_reset`` to keep device in stub.
+ """
if baud is None:
baud = BAUD_RATE()
flash_size = FLASH_SIZE()
+ mode = flash_mode or BOARD_FLASH_MODE()
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} Mode: {mode.upper()}\n")
# Determine if this is a merged binary (flash at 0x0) or app-only (flash at 0x10000)
is_merged = is_merged_binary(firmware_path)
@@ -818,25 +843,83 @@ def flash_firmware(firmware_path, port, esptool_cmd, baud=None):
flash_addr = f"0x{APP_ADDR:x}"
print(f" Detected: app-only binary -> flash at {flash_addr}")
+ before_arg = "no_reset" if no_reset_before else "default_reset"
+ after_arg = "no_reset" if no_hard_reset else "hard_reset"
+
cmd = esptool_cmd + [
"--chip", CHIP,
"--port", port,
"--baud", baud,
- "--before", "default_reset",
- "--after", "hard_reset",
+ "--before", before_arg,
+ "--after", after_arg,
"write_flash",
"-z",
- "--flash_mode", FLASH_MODE,
+ "--flash_mode", mode,
"--flash_freq", FLASH_FREQ,
"--flash_size", flash_size,
- flash_addr, firmware_path,
]
+ if verify:
+ cmd.append("--verify")
+ cmd += [flash_addr, firmware_path]
print("Running: " + " ".join(cmd[-8:]))
result = subprocess.run(cmd)
return result.returncode == 0
+def _monitor_boot(port, timeout=8):
+ """Open serial port and watch for boot errors for `timeout` seconds.
+
+ Returns:
+ (True, output) — device appears to be booting normally
+ (False, output) — bootloop detected (ets_loader.c / repeated resets)
+ (None, reason) — could not open serial port
+ """
+ try:
+ import serial as pyserial
+ except ImportError:
+ return None, "pyserial not installed — skipping boot check"
+
+ try:
+ ser = pyserial.Serial(port, 115200, timeout=1)
+ except Exception as e:
+ return None, f"Could not open {port}: {e}"
+
+ print(f"\n Monitoring boot on {port} for {timeout}s...")
+ output = ""
+ reset_count = 0
+ deadline = time.time() + timeout
+ try:
+ while time.time() < deadline:
+ raw = ser.read(ser.in_waiting or 1)
+ if raw:
+ text = raw.decode("utf-8", errors="replace")
+ output += text
+ # Count ROM reset lines — 2+ means bootloop
+ reset_count += text.count("ets_loader.c")
+ if reset_count >= 2:
+ ser.close()
+ return False, output
+ # Any application output means boot succeeded
+ if "[Boundary]" in output or "RNode" in output or "WiFi" in output:
+ ser.close()
+ return True, output
+ except Exception:
+ pass
+ finally:
+ try:
+ ser.close()
+ except Exception:
+ pass
+
+ # If we got reset output but only once, device may still be trying to boot
+ if reset_count >= 1 and ("ets_loader.c" in output):
+ return False, output
+
+ # No clear signal — assume OK (normal if serial takes time)
+ return True, output
+
+
# ── Main ───────────────────────────────────────────────────────────────────────
def main():
@@ -854,7 +937,7 @@ Examples:
python flash.py --file firmware.bin # Flash a specific file
python flash.py --merge-only # Build merged binary for release
python flash.py --port /dev/ttyACM0 # Specify serial port
- python flash.py --erase --full # Erase flash, then full flash
+ python flash.py --erase # Erase flash, then full flash (auto-verify)
""",
)
parser.add_argument("--board", choices=["v3", "v4"], default=None,
@@ -873,6 +956,9 @@ Examples:
help="Flash merged binary (bootloader + partitions + app) — overwrites everything")
parser.add_argument("--erase", action="store_true",
help="Erase entire flash before writing (implies --full)")
+ # Power-user override (not shown in --help)
+ parser.add_argument("--flash-mode", default=None,
+ help=argparse.SUPPRESS)
args = parser.parse_args()
@@ -933,6 +1019,14 @@ Examples:
if args.erase:
args.full = True
+ # Apply flash mode override (hidden --flash-mode flag for power users)
+ global _flash_mode_override
+ if args.flash_mode:
+ _flash_mode_override = args.flash_mode
+
+ print(f" Flash mode: {BOARD_FLASH_MODE().upper()}"
+ + (" (override)" if _flash_mode_override else " (board default)"))
+
# Determine firmware file
firmware_path = None
merged_fn = MERGED_FILENAME()
@@ -1116,35 +1210,133 @@ Examples:
sys.exit(0)
# ── Erase flash (only when --erase was explicitly passed) ───────────────
+ erase_performed = False
if args.erase:
print(f"\nErasing flash on {port}...")
+ # Use --after no_reset so the device stays in the esptool stub after
+ # erasing. This avoids exiting download mode (which would require
+ # DTR/RTS re-entry and can fail on some USB-UART bridges).
erase_cmd = esptool_cmd + [
"--chip", CHIP,
"--port", port,
"--baud", baud,
+ "--after", "no_reset",
"erase_flash",
]
result = subprocess.run(erase_cmd)
if result.returncode != 0:
print("\nErase FAILED.")
sys.exit(1)
- print("Flash erased. Waiting for device to re-enumerate...")
- time.sleep(3)
+ erase_performed = True
+ print("Flash erased (device still in download mode).")
+ time.sleep(1) # brief settle
- if flash_firmware(firmware_path, port, esptool_cmd, baud):
- print()
- print("╔══════════════════════════════════════════╗")
- print("║ Flash complete! ║")
- print("║ Device will reboot automatically. ║")
- print("║ ║")
- print("║ On first boot, hold PRG > 5s to enter ║")
- print("║ the configuration portal. ║")
- print("╚══════════════════════════════════════════╝")
- else:
+ # ── Flash + auto-verify + boot-check + auto-retry ───────────────────────
+ #
+ # Strategy:
+ # 1. Flash with the board's default flash mode
+ # 2. If this is a full/erase flash, always add --verify
+ # 3. After successful flash+verify, monitor serial for bootloop
+ # 4. If bootloop detected and current mode != DIO, auto-retry with DIO
+ #
+ full_flash_verify = args.full or args.erase
+ current_mode = BOARD_FLASH_MODE()
+
+ ok = flash_firmware(firmware_path, port, esptool_cmd, baud,
+ no_reset_before=erase_performed,
+ verify=full_flash_verify)
+
+ if not ok:
print("\nFlash FAILED. Check connection and try again.")
print("You may need to hold BOOT while pressing RESET.")
sys.exit(1)
+ # ── Post-flash boot monitoring (only on full/erase flashes) ─────────────
+ if full_flash_verify:
+ print("\n Verifying device boots correctly...")
+ time.sleep(2) # Give device time to start booting
+ boot_ok, boot_output = _monitor_boot(port, timeout=8)
+
+ if boot_ok is None:
+ # Couldn't open serial — not fatal, just warn
+ print(f" ⚠ {boot_output}")
+ print(" Cannot verify boot — check device manually")
+ elif boot_ok:
+ print(" ✓ Device is booting normally")
+ else:
+ # Bootloop detected!
+ print("\n ✗ BOOTLOOP DETECTED — device is not booting properly")
+ if boot_output:
+ # Show the first few relevant lines
+ for line in boot_output.splitlines()[:8]:
+ line = line.strip()
+ if line:
+ print(f" {line}")
+
+ if current_mode != "dio":
+ print(f"\n Current flash mode is {current_mode.upper()} — retrying with DIO...")
+ print(" (DIO is more compatible with all flash chip variants)")
+
+ # Need to re-enter download mode: reset via 1200 baud
+ print(" Resetting device to download mode...")
+ reset_to_bootloader(port)
+ time.sleep(3)
+ new_port = args.port or find_serial_port()
+ if new_port:
+ port = new_port
+
+ # Re-erase if we erased before (flash is garbage after bootloop)
+ if args.erase:
+ print(f"\n Re-erasing flash on {port}...")
+ erase_cmd = esptool_cmd + [
+ "--chip", CHIP,
+ "--port", port,
+ "--baud", baud,
+ "--after", "no_reset",
+ "erase_flash",
+ ]
+ result = subprocess.run(erase_cmd)
+ if result.returncode != 0:
+ print("\n Re-erase FAILED.")
+ sys.exit(1)
+ erase_performed = True
+ time.sleep(1)
+
+ ok = flash_firmware(firmware_path, port, esptool_cmd, baud,
+ no_reset_before=erase_performed,
+ verify=True, flash_mode="dio")
+
+ if not ok:
+ print("\n DIO retry FAILED.")
+ sys.exit(1)
+
+ # Check boot again
+ time.sleep(2)
+ boot_ok2, boot_output2 = _monitor_boot(port, timeout=8)
+ if boot_ok2 is False:
+ print("\n ✗ Still bootlooping after DIO retry.")
+ print(" This may be a hardware issue. Check connections and try a different USB cable.")
+ sys.exit(1)
+ elif boot_ok2:
+ print(" ✓ Device is booting normally with DIO mode!")
+ else:
+ print(f" ⚠ {boot_output2}")
+ print(" Could not verify boot — check device manually")
+ else:
+ print("\n Already using DIO mode — this may be a hardware issue.")
+ print(" Try: different USB cable, different port, or reflash the original firmware:")
+ print(f" python flash.py --erase --board {_board}")
+ sys.exit(1)
+
+ print()
+ print("╔══════════════════════════════════════════╗")
+ print("║ Flash complete! ║")
+ print("║ Device will reboot automatically. ║")
+ print("║ ║")
+ print("║ On first boot, hold PRG > 5s to enter ║")
+ print("║ the configuration portal. ║")
+ print("╚══════════════════════════════════════════╝")
+
if __name__ == "__main__":
main()
diff --git a/lib/microReticulum/src/Utilities/OS.cpp b/lib/microReticulum/src/Utilities/OS.cpp
index 20db9af..1d0a70b 100755
--- a/lib/microReticulum/src/Utilities/OS.cpp
+++ b/lib/microReticulum/src/Utilities/OS.cpp
@@ -3,7 +3,7 @@
#include "../Type.h"
#include "../Log.h"
-#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
+#if defined(ESP32)
#include
#endif
@@ -51,31 +51,30 @@ void* operator new(size_t size) {
//if (OS::_tlsf == nullptr) {
if (!_tlsf_init) {
_tlsf_init = true;
-#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
- // Use PSRAM for TLSF pool — frees internal SRAM for WiFi/LoRa/stack.
- // PSRAM is slower (QSPI) but has 2MB vs ~170KB free internal.
- _contiguous_size = ESP.getMaxAllocPsram();
- TRACEF("psram contiguous_size: %u", _contiguous_size);
- if (_buffer_size == 0) {
- _buffer_size = (_contiguous_size * 4) / 5;
+#if defined(ESP32)
+ // Runtime PSRAM detection — works on boards with or without PSRAM.
+ // Boards with PSRAM (e.g. V4 ESP32-S3FH4R2) get TLSF pool in SPIRAM,
+ // freeing internal SRAM for WiFi/LoRa/stack.
+ // Boards without PSRAM (e.g. V3 ESP32-S3FN8) skip TLSF entirely and
+ // use plain malloc() from internal SRAM — the ~170 KB heap is too small
+ // to dedicate to a TLSF pool while still running WiFi/LoRa/FreeRTOS.
+ size_t psram_size = ESP.getPsramSize();
+ void* raw_buffer = nullptr;
+ if (psram_size > 0) {
+ // PSRAM available — allocate TLSF pool from SPIRAM
+ size_t align = tlsf_align_size();
+ _contiguous_size = ESP.getMaxAllocPsram();
+ TRACEF("PSRAM detected: %u bytes total, %u bytes max contiguous", psram_size, _contiguous_size);
+ if (_buffer_size == 0) {
+ _buffer_size = (_contiguous_size * 4) / 5;
+ }
+ _buffer_size &= ~(align - 1);
+ raw_buffer = heap_caps_aligned_alloc(align, _buffer_size, MALLOC_CAP_SPIRAM);
}
- size_t align = tlsf_align_size();
- _buffer_size &= ~(align - 1);
- void* raw_buffer = heap_caps_aligned_alloc(align, _buffer_size, MALLOC_CAP_SPIRAM);
-#elif defined(ESP32)
- // CBA Still unknown why the call to tlsf_create_with_pool() is so flaky on ESP32 with calculated buffer size. Reuires more research and unit tests.
- _contiguous_size = ESP.getMaxAllocHeap();
- TRACEF("contiguous_size: %u", _contiguous_size);
- if (_buffer_size == 0) {
- // CBA NOTE Using fp mathhere somehow causes tlsf_create_with_pool() to fail.
- //_buffer_size = (size_t)(_contiguous_size * BUFFER_FRACTION);
- // Compute 80% exactly using integers
- _buffer_size = (_contiguous_size * 4) / 5;
+ else {
+ // No PSRAM — skip TLSF, all allocations go through malloc()
+ TRACEF("No PSRAM detected (%u bytes internal heap free), TLSF disabled", ESP.getFreeHeap());
}
- // Round DOWN to TLSF alignment
- size_t align = tlsf_align_size();
- _buffer_size &= ~(align - 1);
- void* raw_buffer = (void*)aligned_alloc(align, _buffer_size);
#elif defined(ARDUINO_ARCH_NRF52) || defined(ARDUINO_NRF52_ADAFRUIT)
_contiguous_size = dbgHeapFree();
TRACEF("contiguous_size: %u", _contiguous_size);
diff --git a/platformio.ini b/platformio.ini
index 34b8766..b42b09c 100755
--- a/platformio.ini
+++ b/platformio.ini
@@ -314,7 +314,10 @@ platform = espressif32
board = heltec_wifi_lora_32_V3
custom_variant = heltec32v3
board_build.filesystem = littlefs
-; Flash / memory layout for 8MB flash + 8MB PSRAM (QSPI)
+; Flash / memory layout for 8MB flash
+; PSRAM: V3 ESP32-S3FN8 has NO PSRAM — firmware detects this at runtime
+; and falls back to internal SRAM for TLSF pool.
+; BOARD_HAS_PSRAM tells Arduino to *attempt* psramInit(); harmless if absent.
board_upload.flash_size = 8MB
board_upload.maximum_size = 8388608
board_build.partitions = default_8MB.csv
diff --git a/rnodethv3_firmware.bin b/rnodethv3_firmware.bin
index 64e3333..1c54b1d 100755
Binary files a/rnodethv3_firmware.bin and b/rnodethv3_firmware.bin differ