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