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) ───────────────────────────────────────────
// 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("<label>Bandwidth</label><select name='bw'>");
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 += String(BW_OPTIONS[i].hz);
html += String(bw_hz);
html += "'";
if (BW_OPTIONS[i].hz == cur_bw) html += F(" selected");
if (bw_hz == cur_bw) html += F(" selected");
html += ">";
html += BW_OPTIONS[i].label;
html += label_buf;
html += F("</option>");
}
html += F("</select>");

View File

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

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

View File

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

View File

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

236
flash.py
View File

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

View File

@@ -3,7 +3,7 @@
#include "../Type.h"
#include "../Log.h"
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
#if defined(ESP32)
#include <esp_heap_caps.h>
#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);

View File

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

Binary file not shown.