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
This commit is contained in:
James L
2026-03-01 19:27:22 -05:00
parent 7b71181378
commit c2119edc40
10 changed files with 392 additions and 79 deletions

17
.commitmsg Normal file
View File

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

View File

@@ -39,20 +39,25 @@ bool boundary_needs_config();
// ─── Common bandwidth values (Hz) ─────────────────────────────────────────── // ─── Common bandwidth values (Hz) ───────────────────────────────────────────
// These match Reticulum standard channel plans // These match Reticulum standard channel plans
struct BwOption { uint32_t hz; const char* label; }; // Stored in flash (PROGMEM) to save ~200 bytes of RAM
static const BwOption BW_OPTIONS[] = { static const uint32_t BW_OPTIONS_HZ[] PROGMEM = {
{ 7800, "7.8 kHz" }, 7800, 10400, 15600, 20800, 31250, 41700, 62500, 125000, 250000, 500000,
{ 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" },
}; };
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 ──────────────────────────────────────────────────── // ─── HTML Page Generation ────────────────────────────────────────────────────
@@ -202,12 +207,16 @@ static void config_send_html() {
// Bandwidth — dropdown // Bandwidth — dropdown
html += F("<label>Bandwidth</label><select name='bw'>"); html += F("<label>Bandwidth</label><select name='bw'>");
for (int i = 0; i < BW_OPTIONS_COUNT; i++) { for (int i = 0; i < BW_OPTIONS_COUNT; i++) {
uint32_t bw_hz = pgm_read_dword(&BW_OPTIONS_HZ[i]);
char label_buf[16];
strncpy_P(label_buf, (const char*)pgm_read_ptr(&BW_OPTIONS_LABELS[i]), sizeof(label_buf)-1);
label_buf[sizeof(label_buf)-1] = '\0';
html += F("<option value='"); html += F("<option value='");
html += String(BW_OPTIONS[i].hz); html += String(bw_hz);
html += "'"; html += "'";
if (BW_OPTIONS[i].hz == cur_bw) html += F(" selected"); if (bw_hz == cur_bw) html += F(" selected");
html += ">"; html += ">";
html += BW_OPTIONS[i].label; html += label_buf;
html += F("</option>"); html += F("</option>");
} }
html += F("</select>"); html += F("</select>");

View File

@@ -186,7 +186,8 @@
#define AIRTIME_LONGTERM_MS (AIRTIME_LONGTERM*1000) #define AIRTIME_LONGTERM_MS (AIRTIME_LONGTERM*1000)
#define AIRTIME_BINLEN_MS (STATUS_INTERVAL_MS*DCD_SAMPLES) #define AIRTIME_BINLEN_MS (STATUS_INTERVAL_MS*DCD_SAMPLES)
#define AIRTIME_BINS ((AIRTIME_LONGTERM*1000)/AIRTIME_BINLEN_MS) #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]; uint16_t airtime_bins[AIRTIME_BINS];
float longterm_bins[AIRTIME_BINS]; float longterm_bins[AIRTIME_BINS];
int dcd_sample = 0; int dcd_sample = 0;

View File

@@ -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 - **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 - **Dual board support** — supports both Heltec V3 (8MB flash) and V4 (16MB flash, 2MB PSRAM) with automatic board and PSRAM detection
## Hardware ## 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 | | Component | Heltec V3 | Heltec V4 |
|-----------|-----------|----------| |-----------|-----------|----------|
| **MCU** | ESP32-S3 | ESP32-S3 | | **MCU** | ESP32-S3 (ESP32-S3FN8) | ESP32-S3 (ESP32-S3FH4R2) |
| **Flash** | 8 MB | 16 MB | | **Flash** | 8 MB | 16 MB |
| **PSRAM** | 8 MB (QSPI) | 2 MB (QSPI) | | **PSRAM** | None | 2 MB (QSPI) |
| **Radio** | SX1262 | SX1262 + GC1109 PA | | **Radio** | SX1262 | SX1262 + GC1109 PA |
| **TX Power** | Up to 22 dBm | Up to 28 dBm | | **TX Power** | Up to 22 dBm | Up to 28 dBm |
| **Display** | SSD1306 OLED 128×64 | SSD1306 OLED 128×64 | | **Display** | SSD1306 OLED 128×64 | SSD1306 OLED 128×64 |

View File

@@ -243,6 +243,16 @@ TcpInterface* local_tcp_interface_ptr = nullptr;
// RTC memory flag — survives software reset but not power cycle // RTC memory flag — survives software reset but not power cycle
RTC_NOINIT_ATTR uint32_t boundary_config_request; RTC_NOINIT_ATTR uint32_t boundary_config_request;
#define BOUNDARY_CONFIG_MAGIC 0xC0F19A7E #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
#endif // HAS_RNS #endif // HAS_RNS
@@ -340,11 +350,11 @@ void setup() {
boot_seq(); boot_seq();
#endif #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 // 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 // the full firmware. In the case of the RAK4631, Heltec T114, and Heltec V3,
// until a serial connection is actually established with a master. Thus, it // the line below will wait until a serial connection is actually established
// is disabled on this platform. // with a master. Thus, it is disabled on these platforms.
while (!Serial); while (!Serial);
#endif #endif
@@ -475,13 +485,41 @@ void setup() {
// Load boundary config so the portal can show current/default values // Load boundary config so the portal can show current/default values
boundary_load_config(); 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 need_config = boundary_needs_config();
bool config_requested = (boundary_config_request == BOUNDARY_CONFIG_MAGIC); bool config_requested = (boundary_config_request == BOUNDARY_CONFIG_MAGIC);
boundary_config_request = 0; // Clear flag immediately boundary_config_request = 0; // Clear flag immediately
if (need_config || config_requested) { if (need_config || config_requested || bootloop_detected) {
if (config_requested) { 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"); Serial.println("[Boundary] Config mode requested via button hold");
} else { } else {
Serial.println("[Boundary] No configuration found — starting config portal"); Serial.println("[Boundary] No configuration found — starting config portal");
@@ -515,6 +553,23 @@ void setup() {
btStop(); btStop();
esp_bt_controller_mem_release(ESP_BT_MODE_BTDM); esp_bt_controller_mem_release(ESP_BT_MODE_BTDM);
#endif #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 #endif
if (console_active) { if (console_active) {
@@ -548,6 +603,11 @@ void setup() {
#endif #endif
#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 board health, EEPROM and config
validate_status(); validate_status();
@@ -584,6 +644,11 @@ void setup() {
#ifdef HAS_RNS #ifdef HAS_RNS
try { try {
// Feed WDT before filesystem init (may format on first boot)
#if MCU_VARIANT == MCU_ESP32
esp_task_wdt_reset();
#endif
// CBA Init filesystem // CBA Init filesystem
#if defined(RNS_USE_FS) #if defined(RNS_USE_FS)
filesystem = new FileSystem(); filesystem = new FileSystem();
@@ -593,6 +658,11 @@ void setup() {
((FileSystem*)filesystem.get())->init(); ((FileSystem*)filesystem.get())->init();
#endif #endif
// Feed WDT after filesystem init
#if MCU_VARIANT == MCU_ESP32
esp_task_wdt_reset();
#endif
HEAD("Registering filesystem...", RNS::LOG_TRACE); HEAD("Registering filesystem...", RNS::LOG_TRACE);
RNS::Utilities::OS::register_filesystem(filesystem); RNS::Utilities::OS::register_filesystem(filesystem);
@@ -630,6 +700,11 @@ void setup() {
// CBA Start RNS // CBA Start RNS
if (hw_ready) { 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::setLogCallback(&on_log);
RNS::Transport::set_receive_packet_callback(on_receive_packet); RNS::Transport::set_receive_packet_callback(on_receive_packet);
RNS::Transport::set_transmit_packet_callback(on_transmit_packet); RNS::Transport::set_transmit_packet_callback(on_transmit_packet);
@@ -724,6 +799,11 @@ void setup() {
} }
#endif #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); HEAD("Creating Reticulum instance...", RNS::LOG_TRACE);
reticulum = RNS::Reticulum(); reticulum = RNS::Reticulum();
#ifdef BOUNDARY_MODE #ifdef BOUNDARY_MODE
@@ -1987,12 +2067,17 @@ void check_modem_status() {
update_noise_floor(); update_noise_floor();
#if MCU_VARIANT == MCU_ESP32 || MCU_VARIANT == MCU_NRF52 #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; dcd_sample = (dcd_sample+1)%DCD_SAMPLES;
if (dcd_sample % UTIL_UPDATE_INTERVAL == 0) { if (dcd_sample % UTIL_UPDATE_INTERVAL == 0) {
int util_count = 0; int util_count = 0;
for (int ui = 0; ui < DCD_SAMPLES; ui++) { for (int ui = 0; ui < DCD_BITFIELD_SIZE; ui++) {
if (util_samples[ui]) util_count++; uint8_t b = util_samples[ui];
while (b) { util_count += (b & 1); b >>= 1; }
} }
local_channel_util = (float)util_count / (float)DCD_SAMPLES; local_channel_util = (float)util_count / (float)DCD_SAMPLES;
total_channel_util = local_channel_util + airtime; total_channel_util = local_channel_util + airtime;
@@ -2239,6 +2324,13 @@ void loop() {
} }
#ifdef BOUNDARY_MODE #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 ─────────────────────────────────────────────────── // ── Heap + WiFi watchdog ───────────────────────────────────────────────────
// Monitor heap and WiFi health. Auto-reboot on critical conditions: // Monitor heap and WiFi health. Auto-reboot on critical conditions:
// 1) Internal heap drops below 20KB (WiFi needs ~16KB for RX buffers) // 1) Internal heap drops below 20KB (WiFi needs ~16KB for RX buffers)

View File

@@ -1887,7 +1887,7 @@ void unlock_rom() {
void init_channel_stats() { void init_channel_stats() {
#if MCU_VARIANT == MCU_ESP32 #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++) { airtime_bins[ai] = 0; }
for (uint16_t ai = 0; ai < AIRTIME_BINS; ai++) { longterm_bins[ai] = 0.0; } for (uint16_t ai = 0; ai < AIRTIME_BINS; ai++) { longterm_bins[ai] = 0.0; }
local_channel_util = 0.0; local_channel_util = 0.0;

236
flash.py
View File

@@ -49,10 +49,13 @@ import time
VERSION = "1.0.17" VERSION = "1.0.17"
CHIP = "esp32s3" CHIP = "esp32s3"
FLASH_MODE = "qio" FLASH_MODE = "qio" # Global default; overridden by board profile
FLASH_FREQ = "80m" FLASH_FREQ = "80m"
GITHUB_REPO = "jrl290/RNodeTHV4" 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 # Flash addresses for ESP32-S3 Arduino framework
BOOTLOADER_ADDR = 0x0000 BOOTLOADER_ADDR = 0x0000
PARTITIONS_ADDR = 0x8000 PARTITIONS_ADDR = 0x8000
@@ -72,6 +75,7 @@ BOARD_PROFILES = {
"merged_filename": "rnodethv4_firmware.bin", "merged_filename": "rnodethv4_firmware.bin",
"flash_size": "16MB", "flash_size": "16MB",
"baud_rate": "921600", "baud_rate": "921600",
"flash_mode": "qio", # V4 flash chips support QIO reliably
}, },
"v3": { "v3": {
"name": "Heltec WiFi LoRa 32 V3", "name": "Heltec WiFi LoRa 32 V3",
@@ -81,6 +85,7 @@ BOARD_PROFILES = {
"merged_filename": "rnodethv3_firmware.bin", "merged_filename": "rnodethv3_firmware.bin",
"flash_size": "8MB", "flash_size": "8MB",
"baud_rate": "460800", "baud_rate": "460800",
"flash_mode": "dio", # V3 uses DIO — some flash chips do not support QIO
}, },
} }
DEFAULT_BOARD = "v4" DEFAULT_BOARD = "v4"
@@ -109,6 +114,13 @@ def FLASH_SIZE():
def BAUD_RATE(): def BAUD_RATE():
return board_profile()["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(): def MERGED_FILENAME():
return board_profile()["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" boot_app0: {boot_app0} @ 0x{BOOT_APP0_ADDR:04x}")
print(f" Firmware: {firmware} @ 0x{APP_ADDR:05x}") print(f" Firmware: {firmware} @ 0x{APP_ADDR:05x}")
flash_mode = BOARD_FLASH_MODE()
print(f" Flash mode: {flash_mode.upper()}")
cmd = esptool_cmd + [ cmd = esptool_cmd + [
"--chip", CHIP, "--chip", CHIP,
"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,
@@ -800,13 +814,24 @@ def reset_to_bootloader(port):
return True return True
def flash_firmware(firmware_path, port, esptool_cmd, baud=None): def flash_firmware(firmware_path, port, esptool_cmd, baud=None,
"""Flash firmware to the device.""" 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: if baud is None:
baud = BAUD_RATE() baud = BAUD_RATE()
flash_size = FLASH_SIZE() flash_size = FLASH_SIZE()
mode = flash_mode or BOARD_FLASH_MODE()
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} Mode: {mode.upper()}\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)
@@ -818,25 +843,83 @@ def flash_firmware(firmware_path, port, esptool_cmd, baud=None):
flash_addr = f"0x{APP_ADDR:x}" flash_addr = f"0x{APP_ADDR:x}"
print(f" Detected: app-only binary -> flash at {flash_addr}") 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 + [ cmd = esptool_cmd + [
"--chip", CHIP, "--chip", CHIP,
"--port", port, "--port", port,
"--baud", baud, "--baud", baud,
"--before", "default_reset", "--before", before_arg,
"--after", "hard_reset", "--after", after_arg,
"write_flash", "write_flash",
"-z", "-z",
"--flash_mode", FLASH_MODE, "--flash_mode", mode,
"--flash_freq", FLASH_FREQ, "--flash_freq", FLASH_FREQ,
"--flash_size", flash_size, "--flash_size", flash_size,
flash_addr, firmware_path,
] ]
if verify:
cmd.append("--verify")
cmd += [flash_addr, firmware_path]
print("Running: " + " ".join(cmd[-8:])) print("Running: " + " ".join(cmd[-8:]))
result = subprocess.run(cmd) result = subprocess.run(cmd)
return result.returncode == 0 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 ─────────────────────────────────────────────────────────────────────── # ── Main ───────────────────────────────────────────────────────────────────────
def main(): def main():
@@ -854,7 +937,7 @@ Examples:
python flash.py --file firmware.bin # Flash a specific file python flash.py --file firmware.bin # Flash a specific file
python flash.py --merge-only # Build merged binary for release python flash.py --merge-only # Build merged binary for release
python flash.py --port /dev/ttyACM0 # Specify serial port 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, parser.add_argument("--board", choices=["v3", "v4"], default=None,
@@ -873,6 +956,9 @@ Examples:
help="Flash merged binary (bootloader + partitions + app) — overwrites everything") help="Flash merged binary (bootloader + partitions + app) — overwrites everything")
parser.add_argument("--erase", action="store_true", parser.add_argument("--erase", action="store_true",
help="Erase entire flash before writing (implies --full)") 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() args = parser.parse_args()
@@ -933,6 +1019,14 @@ Examples:
if args.erase: if args.erase:
args.full = True 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 # Determine firmware file
firmware_path = None firmware_path = None
merged_fn = MERGED_FILENAME() merged_fn = MERGED_FILENAME()
@@ -1116,35 +1210,133 @@ Examples:
sys.exit(0) sys.exit(0)
# ── Erase flash (only when --erase was explicitly passed) ─────────────── # ── Erase flash (only when --erase was explicitly passed) ───────────────
erase_performed = False
if args.erase: if args.erase:
print(f"\nErasing flash on {port}...") 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 + [ erase_cmd = esptool_cmd + [
"--chip", CHIP, "--chip", CHIP,
"--port", port, "--port", port,
"--baud", baud, "--baud", baud,
"--after", "no_reset",
"erase_flash", "erase_flash",
] ]
result = subprocess.run(erase_cmd) result = subprocess.run(erase_cmd)
if result.returncode != 0: if result.returncode != 0:
print("\nErase FAILED.") print("\nErase FAILED.")
sys.exit(1) sys.exit(1)
print("Flash erased. Waiting for device to re-enumerate...") erase_performed = True
time.sleep(3) print("Flash erased (device still in download mode).")
time.sleep(1) # brief settle
if flash_firmware(firmware_path, port, esptool_cmd, baud): # ── Flash + auto-verify + boot-check + auto-retry ───────────────────────
print() #
print("╔══════════════════════════════════════════╗") # Strategy:
print("║ Flash complete! ║") # 1. Flash with the board's default flash mode
print("║ Device will reboot automatically. ║") # 2. If this is a full/erase flash, always add --verify
print("║ ║") # 3. After successful flash+verify, monitor serial for bootloop
print("║ On first boot, hold PRG > 5s to enter ║") # 4. If bootloop detected and current mode != DIO, auto-retry with DIO
print("║ the configuration portal. ║") #
print("╚══════════════════════════════════════════╝") full_flash_verify = args.full or args.erase
else: 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("\nFlash FAILED. Check connection and try again.")
print("You may need to hold BOOT while pressing RESET.") print("You may need to hold BOOT while pressing RESET.")
sys.exit(1) 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__": if __name__ == "__main__":
main() main()

View File

@@ -3,7 +3,7 @@
#include "../Type.h" #include "../Type.h"
#include "../Log.h" #include "../Log.h"
#if defined(ESP32) && defined(BOARD_HAS_PSRAM) #if defined(ESP32)
#include <esp_heap_caps.h> #include <esp_heap_caps.h>
#endif #endif
@@ -51,31 +51,30 @@ void* operator new(size_t size) {
//if (OS::_tlsf == nullptr) { //if (OS::_tlsf == nullptr) {
if (!_tlsf_init) { if (!_tlsf_init) {
_tlsf_init = true; _tlsf_init = true;
#if defined(ESP32) && defined(BOARD_HAS_PSRAM) #if defined(ESP32)
// Use PSRAM for TLSF pool — frees internal SRAM for WiFi/LoRa/stack. // Runtime PSRAM detection — works on boards with or without PSRAM.
// PSRAM is slower (QSPI) but has 2MB vs ~170KB free internal. // Boards with PSRAM (e.g. V4 ESP32-S3FH4R2) get TLSF pool in SPIRAM,
_contiguous_size = ESP.getMaxAllocPsram(); // freeing internal SRAM for WiFi/LoRa/stack.
TRACEF("psram contiguous_size: %u", _contiguous_size); // Boards without PSRAM (e.g. V3 ESP32-S3FN8) skip TLSF entirely and
if (_buffer_size == 0) { // use plain malloc() from internal SRAM — the ~170 KB heap is too small
_buffer_size = (_contiguous_size * 4) / 5; // 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(); else {
_buffer_size &= ~(align - 1); // No PSRAM — skip TLSF, all allocations go through malloc()
void* raw_buffer = heap_caps_aligned_alloc(align, _buffer_size, MALLOC_CAP_SPIRAM); TRACEF("No PSRAM detected (%u bytes internal heap free), TLSF disabled", ESP.getFreeHeap());
#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;
} }
// 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) #elif defined(ARDUINO_ARCH_NRF52) || defined(ARDUINO_NRF52_ADAFRUIT)
_contiguous_size = dbgHeapFree(); _contiguous_size = dbgHeapFree();
TRACEF("contiguous_size: %u", _contiguous_size); TRACEF("contiguous_size: %u", _contiguous_size);

View File

@@ -314,7 +314,10 @@ platform = espressif32
board = heltec_wifi_lora_32_V3 board = heltec_wifi_lora_32_V3
custom_variant = heltec32v3 custom_variant = heltec32v3
board_build.filesystem = littlefs 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.flash_size = 8MB
board_upload.maximum_size = 8388608 board_upload.maximum_size = 8388608
board_build.partitions = default_8MB.csv board_build.partitions = default_8MB.csv

Binary file not shown.