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:
17
.commitmsg
Normal file
17
.commitmsg
Normal 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
|
||||
@@ -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>");
|
||||
|
||||
3
Config.h
3
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;
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
236
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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user