diff --git a/lib/ble_interface/BLEInterface.cpp b/lib/ble_interface/BLEInterface.cpp index 2e345c49..e200cc5f 100644 --- a/lib/ble_interface/BLEInterface.cpp +++ b/lib/ble_interface/BLEInterface.cpp @@ -10,6 +10,7 @@ #ifdef ARDUINO #include #include +#include #endif using namespace RNS; @@ -279,6 +280,12 @@ void BLEInterface::loop() { performMaintenance(); _last_maintenance = now; } + + // Process discovered peers (connect attempts) — called OUTSIDE performMaintenance() + // to avoid holding _mutex during the blocking _platform->connect() call. + // The main loop's send_outgoing() acquires _mutex for packet sends — if we held + // _mutex during a 3-6s connect, it would starve the main loop and trigger the WDT. + processDiscoveredPeers(); } //============================================================================= @@ -290,7 +297,14 @@ void BLEInterface::send_outgoing(const Bytes& data) { return; } - std::lock_guard lock(_mutex); + // Non-blocking lock: if BLE task holds _mutex (maintenance, connect, etc.), + // skip this send rather than blocking the main loop. Reticulum handles + // retransmission at the transport layer. + if (!_mutex.try_lock()) { + TRACE("BLEInterface: send_outgoing skipped - BLE task busy"); + return; + } + std::lock_guard lock(_mutex, std::adopt_lock); // Get all connected peers auto connected_peers = _peer_manager.getConnectedPeers(); @@ -367,8 +381,10 @@ bool BLEInterface::sendToPeer(const Bytes& peer_identity, const Bytes& data) { bool sent = false; if (peer->is_central) { - // We are central - write to peripheral (with response for debugging) - sent = _platform->write(peer->conn_handle, fragment, true); + // We are central - write to peripheral (no response = non-blocking) + // Reticulum handles retransmission, so BLE-level ACK is unnecessary + // and write-with-response blocks until peer acknowledges or disconnects + sent = _platform->write(peer->conn_handle, fragment, false); } else { // We are peripheral - notify central sent = _platform->notify(peer->conn_handle, fragment); @@ -860,52 +876,64 @@ void BLEInterface::processDiscoveredPeers() { return; // Still in cooldown period } - // Find best connection candidate - PeerInfo* candidate = _peer_manager.getBestConnectionCandidate(); + // Prepare connection candidate under short-lived lock — DO NOT hold _mutex + // during the blocking _platform->connect() call. The main loop calls + // send_outgoing() which acquires _mutex, and a 3-6s connect would starve it. + BLEAddress addr; + Bytes candidate_mac; + bool should_connect = false; + { + std::lock_guard lock(_mutex); - // Debug: log all peers and why they may not be candidates - static double last_peer_log = 0; - if (now - last_peer_log >= 10.0) { - auto all_peers = _peer_manager.getAllPeers(); - INFO("BLE: Peers=" + std::to_string(all_peers.size()) + - " localMAC=" + _peer_manager.getLocalMac().toString()); - for (PeerInfo* peer : all_peers) { - if (peer->mac_address.size() < Limits::MAC_SIZE) { - WARNING("BLE: Peer with empty MAC, state=" + - std::to_string(static_cast(peer->state))); - continue; + PeerInfo* candidate = _peer_manager.getBestConnectionCandidate(); + + // Debug: log all peers and why they may not be candidates + static double last_peer_log = 0; + if (now - last_peer_log >= 10.0) { + auto all_peers = _peer_manager.getAllPeers(); + INFO("BLE: Peers=" + std::to_string(all_peers.size()) + + " localMAC=" + _peer_manager.getLocalMac().toString()); + for (PeerInfo* peer : all_peers) { + if (peer->mac_address.size() < Limits::MAC_SIZE) { + WARNING("BLE: Peer with empty MAC, state=" + + std::to_string(static_cast(peer->state))); + continue; + } + bool should_initiate = _peer_manager.shouldInitiateConnection(peer->mac_address); + INFO("BLE: Peer " + BLEAddress(peer->mac_address.data()).toString() + + " state=" + std::to_string(static_cast(peer->state)) + + " shouldInit=" + std::string(should_initiate ? "yes" : "no") + + " score=" + std::to_string(peer->score)); } - bool should_initiate = _peer_manager.shouldInitiateConnection(peer->mac_address); - INFO("BLE: Peer " + BLEAddress(peer->mac_address.data()).toString() + - " state=" + std::to_string(static_cast(peer->state)) + - " shouldInit=" + std::string(should_initiate ? "yes" : "no") + - " score=" + std::to_string(peer->score)); + last_peer_log = now; } - last_peer_log = now; - } - if (candidate && candidate->mac_address.size() >= Limits::MAC_SIZE) { - INFO("BLE: Connection candidate: " + BLEAddress(candidate->mac_address.data()).toString() + - " type=" + std::to_string(candidate->address_type) + - " canAccept=" + std::string(_peer_manager.canAcceptConnection() ? "yes" : "no")); - } + if (candidate && candidate->mac_address.size() >= Limits::MAC_SIZE) { + INFO("BLE: Connection candidate: " + BLEAddress(candidate->mac_address.data()).toString() + + " type=" + std::to_string(candidate->address_type) + + " canAccept=" + std::string(_peer_manager.canAcceptConnection() ? "yes" : "no")); + } - if (candidate && _peer_manager.canAcceptConnection()) { - _peer_manager.setPeerState(candidate->mac_address, PeerState::CONNECTING); - candidate->connection_attempts++; + if (candidate && _peer_manager.canAcceptConnection()) { + _peer_manager.setPeerState(candidate->mac_address, PeerState::CONNECTING); + candidate->connection_attempts++; - // Use stored address type for correct connection - BLEAddress addr(candidate->mac_address.data(), candidate->address_type); - INFO("BLEInterface: Connecting to " + addr.toString() + " type=" + std::to_string(candidate->address_type)); + // Copy address data before releasing lock + addr = BLEAddress(candidate->mac_address.data(), candidate->address_type); + candidate_mac = candidate->mac_address; + should_connect = true; - // Mark connection attempt time for cooldown - _last_connection_attempt = now; + INFO("BLEInterface: Connecting to " + addr.toString() + " type=" + std::to_string(candidate->address_type)); + _last_connection_attempt = now; + } + } // _mutex released here — before the blocking connect - // Handle immediate connection failure (resets state for retry) - // Reduced timeout from 10s to 3s to avoid long UI freezes + if (should_connect) { + // Blocking connect (3-6s) — _mutex NOT held, main loop can send packets if (!_platform->connect(addr, 3000)) { WARNING("BLEInterface: Connection attempt failed immediately"); - _peer_manager.connectionFailed(candidate->mac_address); + std::lock_guard lock(_mutex); + _peer_manager.connectionFailed(candidate_mac); } } } @@ -1009,8 +1037,10 @@ void BLEInterface::performMaintenance() { } } - // Process discovered peers (try to connect) - processDiscoveredPeers(); + // NOTE: processDiscoveredPeers() is called separately from loop() to avoid + // holding _mutex during blocking connect operations (3-6 seconds). If held + // here, the main loop's send_outgoing() would block on _mutex, triggering + // the Task Watchdog. } void BLEInterface::handleIncomingData(const ConnectionHandle& conn, const Bytes& data) { @@ -1081,7 +1111,12 @@ void BLEInterface::ble_task(void* param) { BLEInterface* self = static_cast(param); Serial.printf("BLE task started on core %d\n", xPortGetCoreID()); + // Subscribe BLE task to Task Watchdog — detects BLE deadlocks + esp_task_wdt_add(NULL); + while (true) { + esp_task_wdt_reset(); + // Run the BLE loop (already has internal mutex protection) self->loop(); diff --git a/lib/ble_interface/platforms/NimBLEPlatform.cpp b/lib/ble_interface/platforms/NimBLEPlatform.cpp index c683694e..b041de90 100644 --- a/lib/ble_interface/platforms/NimBLEPlatform.cpp +++ b/lib/ble_interface/platforms/NimBLEPlatform.cpp @@ -8,8 +8,10 @@ #if defined(ESP32) && (defined(USE_NIMBLE) || defined(CONFIG_BT_NIMBLE_ENABLED)) #include "Log.h" +#include "Identity.h" #include #include +#include // WiFi coexistence: Check if WiFi is available and connected // This is used to add extra delays before BLE connection attempts @@ -284,6 +286,7 @@ void NimBLEPlatform::shutdown() { " active write operation(s)"); // DELAY RATIONALE: Shutdown wait polling - check every 100ms for write completion delay(100); + esp_task_wdt_reset(); } // Check if we timed out @@ -356,58 +359,18 @@ bool NimBLEPlatform::isRunning() const { //============================================================================= bool NimBLEPlatform::recoverBLEStack() { - // CONC-M4: Enhanced soft reset with graceful shutdown - INFO("NimBLEPlatform: Soft reset requested"); + // NimBLEDevice::deinit() frees memory that the NimBLE host task may have + // corrupted during sync failures, causing CORRUPT HEAP panics. The only + // safe recovery is a full reboot. With atomic file persistence, data + // survives reboots reliably. + ERROR("NimBLEPlatform: BLE stack stuck - persisting data and rebooting"); - // Track consecutive recovery attempts using existing member variable - _lightweight_reset_fails++; - WARNING("NimBLEPlatform: Performing soft BLE reset (attempt " + - std::to_string(_lightweight_reset_fails) + ")..."); + // Persist any dirty data before reboot + RNS::Identity::persist_data(); - // If we've had too many consecutive recovery attempts without success, - // the BLE stack is truly stuck. Reboot is the only reliable fix. - if (_lightweight_reset_fails >= 5) { - ERROR("NimBLEPlatform: BLE stack unrecoverable after " + - std::to_string(_lightweight_reset_fails) + " attempts - rebooting device"); - // DELAY RATIONALE: Stack init settling - allow log message to flush before reboot - delay(100); - ESP.restart(); - return false; // Won't reach here - } - - // CONC-M4: Use graceful shutdown to wait for active operations - // This ensures write operations complete before we reset state - // Save config before shutdown clears it - PlatformConfig saved_config = _config; - - // Perform graceful shutdown (waits for writes, cleans up properly) - shutdown(); - - // Brief delay for NimBLE host task to process shutdown - // DELAY RATIONALE: Soft reset processing - allow stack to fully quiesce after deinit delay(100); - - // Reinitialize with saved config - if (!initialize(saved_config)) { - WARNING("NimBLEPlatform: Soft reset reinitialization failed"); - // Log detailed state for debugging - ERROR("NimBLEPlatform: Soft reset failed - stack may need hard recovery (ESP.restart)"); - // Return false - caller can decide to trigger ESP.restart() if needed - return false; - } - - // Restart the platform - if (!start()) { - WARNING("NimBLEPlatform: Soft reset start failed"); - return false; - } - - // Reset failure counters on successful reset - _lightweight_reset_fails = 0; - _scan_fail_count = 0; - - INFO("NimBLEPlatform: Soft reset complete"); - return true; + ESP.restart(); + return false; // Won't reach here } //============================================================================= @@ -523,6 +486,7 @@ bool NimBLEPlatform::pauseSlaveForMaster() { while (ble_gap_adv_active() && millis() - start < 2000) { // DELAY RATIONALE: Advertising stop polling - check completion every NimBLE scheduler tick (~10ms) delay(10); + esp_task_wdt_reset(); // Feed WDT during blocking wait } if (ble_gap_adv_active()) { @@ -557,6 +521,7 @@ bool NimBLEPlatform::pauseSlaveForMaster() { } // DELAY RATIONALE: Slave state polling - check completion every NimBLE scheduler tick (~10ms) delay(10); + esp_task_wdt_reset(); } WARNING("NimBLEPlatform: Timed out waiting for slave to become idle"); @@ -627,6 +592,7 @@ void NimBLEPlatform::enterErrorRecovery() { uint32_t sync_start = millis(); while (!ble_hs_synced() && (millis() - sync_start) < 3000) { delay(50); + esp_task_wdt_reset(); } if (ble_hs_synced()) { INFO("NimBLEPlatform: Host sync restored after " + @@ -702,6 +668,7 @@ bool NimBLEPlatform::startScan(uint16_t duration_ms) { uint32_t sync_wait = millis(); while (!ble_hs_synced() && (millis() - sync_wait) < 2000) { delay(50); + esp_task_wdt_reset(); } if (!ble_hs_synced()) { _scan_fail_count++; @@ -828,6 +795,7 @@ void NimBLEPlatform::stopScan() { while (ble_gap_disc_active() && millis() - start < 1000) { // DELAY RATIONALE: Scan stop polling - check completion every NimBLE scheduler tick (~10ms) delay(10); + esp_task_wdt_reset(); } // Transition to IDLE @@ -942,6 +910,7 @@ bool NimBLEPlatform::connect(const BLEAddress& address, uint16_t timeout_ms) { while (ble_gap_conn_active() && millis() - start < 1000) { // DELAY RATIONALE: Service discovery polling - check completion per scheduler tick delay(10); + esp_task_wdt_reset(); } if (ble_gap_conn_active()) { ERROR("NimBLEPlatform: GAP connection still active after timeout"); @@ -1161,9 +1130,14 @@ bool NimBLEPlatform::connectNative(const BLEAddress& address, uint16_t timeout_m // discovery) that would deadlock the host task. _native_connect_pending = true; + // Feed WDT before blocking connect — NimBLE connect can take several seconds + // with WiFi coexistence, and the BLE task is subscribed to the 10s WDT + esp_task_wdt_reset(); + // Connect (blocking) — NimBLE handles GAP event management internally bool connected = client->connect(nimAddr, false); // deleteAttributes=false + esp_task_wdt_reset(); // Feed WDT after connect returns _native_connect_pending = false; if (!connected) { @@ -1334,6 +1308,7 @@ bool NimBLEPlatform::startAdvertising() { uint32_t sync_wait = millis(); while (!ble_hs_synced() && (millis() - sync_wait) < 1000) { delay(50); + esp_task_wdt_reset(); } if (!ble_hs_synced()) { DEBUG("NimBLEPlatform: Host not synced, cannot start advertising"); @@ -1404,6 +1379,7 @@ void NimBLEPlatform::stopAdvertising() { while (ble_gap_adv_active() && millis() - start < 1000) { // DELAY RATIONALE: Loop iteration throttle - prevent tight loop CPU consumption delay(10); + esp_task_wdt_reset(); } // Transition to IDLE diff --git a/patch_nimble.py b/patch_nimble.py index 67169da1..6d572f39 100644 --- a/patch_nimble.py +++ b/patch_nimble.py @@ -1,52 +1,78 @@ """ -PlatformIO pre-build script: Patch NimBLE ble_hs.c assert(0) in timer expiry handler. +PlatformIO pre-build script: Patch NimBLE stability issues. -NimBLE's ble_hs_timer_exp() asserts when a timer fires during BLE_HS_SYNC_STATE_BRINGUP. -This is a race condition (timer scheduled before host reset wasn't cancelled), not a fatal -error. The assert kills the ESP32, corrupting any file writes in progress. +Patch 1 — ble_hs.c: Remove assert(0) in BLE_HS_SYNC_STATE_BRINGUP timer handler. + Timer can fire during host re-sync due to a race condition. Harmless — just ignore it. -Fix: Replace assert(0) with break — the timer is harmless during bringup; the sync -process will reschedule timers when transitioning to GOOD state. +Patch 2 — NimBLEClient.cpp: Add null checks in PHY update event handler. + If a client is deleted while events are queued, the callback arg becomes a dangling + pointer. Guard against null pClient and null m_pClientCallbacks. """ Import("env") import os -def patch_nimble_ble_hs(env): - ble_hs_path = os.path.join( - env.get("PROJECT_DIR", "."), - ".pio", "libdeps", "tdeck", - "NimBLE-Arduino", "src", "nimble", "nimble", "host", "src", "ble_hs.c" - ) +NIMBLE_BASE = os.path.join( + env.get("PROJECT_DIR", "."), + ".pio", "libdeps", "tdeck", "NimBLE-Arduino", "src" +) - if not os.path.exists(ble_hs_path): - print("PATCH: NimBLE ble_hs.c not found, skipping patch") +def apply_patch(filepath, old, new, label): + if not os.path.exists(filepath): + print(f"PATCH: {os.path.basename(filepath)} not found, skipping {label}") return - - with open(ble_hs_path, "r") as f: + with open(filepath, "r") as f: content = f.read() + if old in content: + content = content.replace(old, new) + with open(filepath, "w") as f: + f.write(content) + print(f"PATCH: {label}") + elif new in content: + print(f"PATCH: {label} (already applied)") + else: + print(f"PATCH: WARNING -- {label}: expected code not found") - # Only patch if the assert is still there (idempotent) - old = """ case BLE_HS_SYNC_STATE_BRINGUP: +# Patch 1: ble_hs.c timer assert +apply_patch( + os.path.join(NIMBLE_BASE, "nimble", "nimble", "host", "src", "ble_hs.c"), + """ case BLE_HS_SYNC_STATE_BRINGUP: default: /* The timer should not be set in this state. */ assert(0); - break;""" - - new = """ case BLE_HS_SYNC_STATE_BRINGUP: + break;""", + """ case BLE_HS_SYNC_STATE_BRINGUP: default: /* Timer can fire during bringup due to race with host reset. * This is harmless — bringup will reschedule when ready. */ - break;""" + break;""", + "ble_hs.c: removed assert(0) in BRINGUP timer handler" +) - if old in content: - content = content.replace(old, new) - with open(ble_hs_path, "w") as f: - f.write(content) - print("PATCH: Patched NimBLE ble_hs.c -- removed assert(0) in BRINGUP timer handler") - elif new in content: - print("PATCH: NimBLE ble_hs.c already patched") - else: - print("PATCH: WARNING -- Could not find expected code in ble_hs.c, manual review needed") +# Patch 2: NimBLEClient.cpp PHY update null guard +apply_patch( + os.path.join(NIMBLE_BASE, "NimBLEClient.cpp"), + """ case BLE_GAP_EVENT_PHY_UPDATE_COMPLETE: { + NimBLEConnInfo peerInfo; + rc = ble_gap_conn_find(event->phy_updated.conn_handle, &peerInfo.m_desc); + if (rc != 0) { + return BLE_ATT_ERR_INVALID_HANDLE; + } -# Run the patch immediately during script evaluation (before any build targets) -patch_nimble_ble_hs(env) + pClient->m_pClientCallbacks->onPhyUpdate(pClient, event->phy_updated.tx_phy, event->phy_updated.rx_phy); + return 0; + } // BLE_GAP_EVENT_PHY_UPDATE_COMPLETE""", + """ case BLE_GAP_EVENT_PHY_UPDATE_COMPLETE: { + if (pClient == nullptr || pClient->m_pClientCallbacks == nullptr) { + return 0; + } + NimBLEConnInfo peerInfo; + rc = ble_gap_conn_find(event->phy_updated.conn_handle, &peerInfo.m_desc); + if (rc != 0) { + return BLE_ATT_ERR_INVALID_HANDLE; + } + + pClient->m_pClientCallbacks->onPhyUpdate(pClient, event->phy_updated.tx_phy, event->phy_updated.rx_phy); + return 0; + } // BLE_GAP_EVENT_PHY_UPDATE_COMPLETE""", + "NimBLEClient.cpp: added null guard in PHY update handler" +) diff --git a/platformio.ini b/platformio.ini index cb178f16..4954aaf1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -52,11 +52,10 @@ build_flags = -std=gnu++11 -DBOARD_HAS_PSRAM -DBOARD_ESP32 - ; Increase Arduino loop task stack from 8KB to 48KB - ; Deep call chains through Link resource callbacks + ; Arduino loop task stack — measured peak is ~6KB (stack_hwm=43380/49152) + ; 16KB gives ~2.5x headroom for deep call chains ; (transport → link → resource → LXMF unpack → crypto → msgpack) - ; require significant stack space, especially for large messages - -DARDUINO_LOOP_STACK_SIZE=49152 + -DARDUINO_LOOP_STACK_SIZE=16384 -DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_MODE=1 -DLV_CONF_INCLUDE_SIMPLE @@ -134,11 +133,10 @@ build_flags = -std=gnu++11 -DBOARD_HAS_PSRAM -DBOARD_ESP32 - ; Increase Arduino loop task stack from 8KB to 48KB - ; Deep call chains through Link resource callbacks + ; Arduino loop task stack — measured peak is ~6KB (stack_hwm=43380/49152) + ; 16KB gives ~2.5x headroom for deep call chains ; (transport → link → resource → LXMF unpack → crypto → msgpack) - ; require significant stack space, especially for large messages - -DARDUINO_LOOP_STACK_SIZE=49152 + -DARDUINO_LOOP_STACK_SIZE=16384 -DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_MODE=1 -DLV_CONF_INCLUDE_SIMPLE diff --git a/src/main.cpp b/src/main.cpp index 2162dfae..7b04533d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include // placement new #include @@ -1193,6 +1194,12 @@ void setup() { INFO("╚══════════════════════════════════════╝"); INFO(""); + // Subscribe main loop to Task Watchdog — detects hangs/deadlocks + // If loop() blocks for >10s (CONFIG_ESP_TASK_WDT_TIMEOUT_S), WDT fires + // with a backtrace showing exactly where the hang is + esp_task_wdt_add(NULL); // NULL = current task (loopTask) + INFO("Task Watchdog: loopTask subscribed"); + // Show startup message INFO("Press any key to start messaging"); } @@ -1200,7 +1207,16 @@ void setup() { // Serial command buffer for web flasher detection static String serial_cmd_buffer = ""; +// Loop step tracker — helps identify which call blocks when device hangs +// Written every loop iteration, printed in 5s heap diagnostic +static volatile uint8_t loop_step = 0; + +// Feed WDT and advance loop step tracker +#define LOOP_STEP(n) do { loop_step = (n); esp_task_wdt_reset(); } while(0) + void loop() { + esp_task_wdt_reset(); + // Handle serial commands for web flasher detection while (Serial.available()) { char c = Serial.read(); @@ -1222,13 +1238,16 @@ void loop() { } } + LOOP_STEP(1); // LVGL task_handler // Handle LVGL rendering (must be called frequently for smooth UI) UI::LVGL::LVGLInit::task_handler(); + LOOP_STEP(2); // Display health // Monitor display health Hardware::TDeck::Display::log_health(); // Handle deferred WiFi reconnect (from LVGL task) + LOOP_STEP(3); // WiFi reconnect check if (wifi_reconnect_pending) { wifi_reconnect_pending = false; INFO(("Reconnecting WiFi to: " + pending_wifi_ssid).c_str()); @@ -1238,6 +1257,7 @@ void loop() { uint32_t start = millis(); while (WiFi.status() != WL_CONNECTED && millis() - start < 10000) { + esp_task_wdt_reset(); delay(100); } @@ -1251,43 +1271,52 @@ void loop() { } // Process Reticulum + LOOP_STEP(4); // reticulum->loop() reticulum->loop(); // Periodically persist identity/transport data (display names, paths, etc.) + LOOP_STEP(5); // persist data reticulum->should_persist_data(); // Fast-persist known destinations (5s after dirty) to survive crashes Identity::should_persist_data(); // Process TCP interface + LOOP_STEP(6); // TCP loop if (tcp_interface) { tcp_interface->loop(); } // Process LoRa interface + LOOP_STEP(7); // LoRa loop if (lora_interface) { lora_interface->loop(); } // Process BLE interface (skip if running on its own task) + LOOP_STEP(8); // BLE loop if (ble_interface && ble_interface_impl && !ble_interface_impl->is_task_running()) { ble_interface->loop(); } // Process LXMF router queues + LOOP_STEP(9); // Router processing if (router) { router->process_outbound(); router->process_inbound(); } // Update UI manager (processes LXMF messages) + LOOP_STEP(10); // UI manager update if (ui_manager) { ui_manager->update(); } + LOOP_STEP(11); // Memory monitor // Process deferred memory monitor logging (flag set by timer callback) MEMORY_MONITOR_POLL(); // Periodic announce (using interval from settings) + LOOP_STEP(12); // Periodic tasks if (app_settings.announce_interval > 0) { // 0 = disabled uint32_t announce_interval_ms = app_settings.announce_interval * 1000; if (millis() - last_announce > announce_interval_ms) { @@ -1406,6 +1435,7 @@ void loop() { } // Screen timeout handling + LOOP_STEP(13); // Screen timeout if (app_settings.screen_timeout > 0) { // 0 = never timeout uint32_t inactive_ms; { @@ -1467,8 +1497,8 @@ void loop() { int32_t delta = (last_free_heap > 0) ? ((int32_t)free_heap - (int32_t)last_free_heap) : 0; UBaseType_t stack_hwm = uxTaskGetStackHighWaterMark(NULL); - Serial.printf("[HEAP] free=%u min=%u max_block=%u delta=%+d stack_hwm=%u\n", - free_heap, min_heap, max_block, delta, stack_hwm); + Serial.printf("[HEAP] free=%u min=%u max_block=%u delta=%+d stack_hwm=%u step=%u\n", + free_heap, min_heap, max_block, delta, stack_hwm, (unsigned)loop_step); // PSRAM diagnostics uint32_t psram_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); uint32_t psram_total = ESP.getPsramSize();