diff --git a/src/LvglPsramAlloc.cpp b/src/LvglPsramAlloc.cpp index 0f402d4..485aac0 100644 --- a/src/LvglPsramAlloc.cpp +++ b/src/LvglPsramAlloc.cpp @@ -40,9 +40,19 @@ extern "C" void* lvglPsramRealloc(void* ptr, size_t size) { if (!ptr) { return lvglPsramAlloc(size); } - // Plain realloc preserves whatever heap the original block was on (PSRAM - // stays in PSRAM, DRAM stays in DRAM). Migrating between heaps on realloc - // would need to know the original size to memcpy safely; not worth the - // complexity for LVGL's usage pattern. +#if defined(ESP32) + // ESP-IDF documents plain realloc(ptr, size) as + // heap_caps_realloc(ptr, size, MALLOC_CAP_8BIT). That generic capability can + // move a PSRAM-backed LVGL block into the higher-priority internal heap. The + // UI performs many small text/style reallocations while building its tree, + // so the old implementation silently migrated roughly 60 KB back into the + // DRAM needed by Wi-Fi/BLE connection and security work. Preserve the PSRAM + // requirement explicitly. heap_caps_realloc handles copying even when ptr + // came from the internal fallback in lvglPsramAlloc(). + void* p = heap_caps_realloc(ptr, size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (p) return p; +#endif + // PSRAM absent or exhausted: retain the original fallback semantics. A failed + // heap_caps_realloc leaves ptr valid, so the ordinary realloc remains safe. return realloc(ptr, size); } diff --git a/src/helpers/esp32/MultiTransportCompanionInterface.cpp b/src/helpers/esp32/MultiTransportCompanionInterface.cpp index d65404e..335217f 100644 --- a/src/helpers/esp32/MultiTransportCompanionInterface.cpp +++ b/src/helpers/esp32/MultiTransportCompanionInterface.cpp @@ -2,6 +2,7 @@ #include #include "WifiRuntimeStore.h" // persist BLE on/off (ble_en) across reboots #include "WebMirror.h" // web UI mirror bridge (served over the WS server) +#include #include // Companion push code for the per-packet RX log (matches MyMesh.cpp). It is kept OFF @@ -14,7 +15,7 @@ bool MultiTransportCompanionInterface::s_ble_rxlog_once = false; MultiTransportCompanionInterface::MultiTransportCompanionInterface() : _tcp_port(0), _ws_port(0), _tcp_started(false), _ws_started(false), _tcp_enabled(true), _isEnabled(false), _broadcast(false), _last_reply_target(REPLY_TARGET_USB), _ota_tcp_suspended(false), _ota_ws_suspended(false), _ota_ws_listen_paused(false) #ifdef BLE_PIN_CODE - , _ble_begun(false), _ble_enabled(false), _ota_ble_released(false), _ble_pin_code(0) + , _ble_begun(false), _ble_enabled(false), _ota_ble_released(false), _ota_ble_was_enabled(false), _ble_pin_code(0) #endif { for (size_t i = 0; i < sizeof(_client_ids) / sizeof(_client_ids[0]); i++) @@ -183,13 +184,15 @@ void MultiTransportCompanionInterface::prepareBle(const char* prefix, char* name _ble_pin_code = pin_code; } -void MultiTransportCompanionInterface::beginBle(const char* prefix, char* name, uint32_t pin_code) { +void MultiTransportCompanionInterface::beginBle(const char* prefix, char* name, uint32_t pin_code, + bool create_enabled) { prepareBle(prefix, name, pin_code); _ble.begin(prefix, name, pin_code); _ble_begun = true; - _ble_enabled = true; + _ble_enabled = create_enabled; _ota_ble_released = false; - _ble.enable(); + _ota_ble_was_enabled = false; + if (create_enabled) _ble.enable(); } void MultiTransportCompanionInterface::enableBle() { @@ -197,6 +200,31 @@ void MultiTransportCompanionInterface::enableBle() { // Deferred at boot (heap guard) or toggled on from off: bring the stack up // now, live, from the params stashed by prepareBle()/beginBle(). if (_ble_prefix[0] == '\0' && _ble_name[0] == '\0') return; // no params known +#if defined(TLORA_PAGER) + // The Pager must claim Wi-Fi before NimBLE. A cold BLE start after Wi-Fi + // exists can both violate that ordering and consume the last contiguous + // internal block after LVGL is built. Refuse here; UITask persists the + // request and reboots through setup's proven Wi-Fi -> BLE -> UI sequence. + if (WiFi.getMode() != WIFI_MODE_NULL) { + Serial.println("[ble] cold start deferred to ordered T-Pager reboot"); + return; + } +#endif + // Only a cold NimBLE start needs the coexistence reserve. Re-enabling an + // already-created stack below this threshold is allocation-free and must + // not be rejected (UITask used to gate both cases identically, trapping the + // user with BLE resident-but-off and neither radio enableable). + const size_t BLE_COEXIST_MIN_FREE = 50u * 1024u; + const size_t BLE_COEXIST_MIN_BLOCK = 20u * 1024u; + const uint32_t internal_caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; + const size_t internal_free = heap_caps_get_free_size(internal_caps); + const size_t internal_max = heap_caps_get_largest_free_block(internal_caps); + if (internal_free < BLE_COEXIST_MIN_FREE || + internal_max < BLE_COEXIST_MIN_BLOCK) { + Serial.printf("[ble] cold start refused: free=%u maxblk=%u\n", + (unsigned)internal_free, (unsigned)internal_max); + return; + } char name[sizeof(_ble_name)]; strncpy(name, _ble_name, sizeof(name) - 1); name[sizeof(name) - 1] = '\0'; @@ -211,11 +239,18 @@ void MultiTransportCompanionInterface::enableBle() { void MultiTransportCompanionInterface::disableBle() { _ble_enabled = false; wifiConfigSetBleEnabled(false); // persist so BT stays off across reboot - _ble.disable(); // stop advertising + drop any connection - // NOTE: we deliberately do NOT NimBLEDevice::deinit() here. Tearing the BT - // controller down while Wi-Fi+BLE coexistence is active crashes — the esp_coex - // layer still holds a reference to the controller — so "off" stops advertising - // but keeps the NimBLE host resident. Its RAM is only fully reclaimed on reboot. + // deinit(true) below deletes NimBLE's server, but SerialBLEInterface keeps + // its cached server pointer. Never call disable() again after that teardown; + // a later begin() replaces the cached pointers with a fresh GATT server. + if (_ble_begun) _ble.disable(); // stop advertising + drop any connection + // If Wi-Fi is genuinely absent, fully release NimBLE so a later Wi-Fi enable + // has the contiguous internal heap esp_wifi_init needs. While Wi-Fi exists we + // keep the controller resident: tearing it out from under an active coex path + // is unsafe, and re-enabling the already-created stack needs no allocation. + if (_ble_begun && WiFi.getMode() == WIFI_MODE_NULL) { + NimBLEDevice::deinit(true); + _ble_begun = false; + } } bool MultiTransportCompanionInterface::getBlePeerAddress(char* buf, size_t len) const { @@ -241,7 +276,9 @@ void MultiTransportCompanionInterface::disable() { _isEnabled = false; _usb.disable(); #ifdef BLE_PIN_CODE - _ble.disable(); + // A prior disableBle() may have fully deinitialised NimBLE and left the + // wrapped SerialBLEInterface's cached server pointer dangling. + if (_ble_begun) _ble.disable(); #endif } @@ -288,8 +325,9 @@ void MultiTransportCompanionInterface::prepareForHttpOta() { } #ifdef BLE_PIN_CODE - if (_ble_begun && _ble_enabled) { - _ble.disable(); + if (_ble_begun) { + _ota_ble_was_enabled = _ble_enabled; + if (_ble_enabled) _ble.disable(); NimBLEDevice::deinit(true); _ble_begun = false; _ble_enabled = false; @@ -320,9 +358,10 @@ void MultiTransportCompanionInterface::restoreAfterHttpOta() { ble_name[sizeof(ble_name) - 1] = '\0'; _ble.begin(_ble_prefix, ble_name, _ble_pin_code); _ble_begun = true; - _ble_enabled = true; - _ble.enable(); + _ble_enabled = _ota_ble_was_enabled; + if (_ble_enabled) _ble.enable(); _ota_ble_released = false; + _ota_ble_was_enabled = false; meshcoreRepeaterTcpOtaEmitLine("OTA: restored BLE stack"); } #endif diff --git a/src/helpers/esp32/MultiTransportCompanionInterface.h b/src/helpers/esp32/MultiTransportCompanionInterface.h index aabb5e4..6b40092 100644 --- a/src/helpers/esp32/MultiTransportCompanionInterface.h +++ b/src/helpers/esp32/MultiTransportCompanionInterface.h @@ -31,7 +31,11 @@ public: #ifdef BLE_PIN_CODE // Call after begin() and the_mesh is ready (e.g. after startInterface). Enables BLE by default. - void beginBle(const char* prefix, char* name, uint32_t pin_code); + // create_enabled=false pre-creates the NimBLE host/GATT table but leaves + // advertising off and the user-visible/persisted state disabled. T-Pager + // uses this after Wi-Fi claims its heap so a later live toggle allocates + // nothing after the UI working set has filled internal DRAM. + void beginBle(const char* prefix, char* name, uint32_t pin_code, bool create_enabled = true); // Store the BLE name/pin WITHOUT bringing the stack up. Used at boot when the // heap guard defers co-initialising BLE alongside Wi-Fi: the params are kept so // a later enableBle() can lazily bring BLE up live (no reboot). @@ -135,6 +139,7 @@ private: bool _ble_begun; // beginBle() was called bool _ble_enabled; // user has BLE on (toggle via UI) bool _ota_ble_released; + bool _ota_ble_was_enabled; char _ble_prefix[24]; char _ble_name[48]; uint32_t _ble_pin_code; diff --git a/src/main.cpp b/src/main.cpp index ada7dd1..2020807 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -65,7 +65,11 @@ static uint32_t _atoi(const char* sp) { #ifdef ESP32 #ifdef MULTI_TRANSPORT_COMPANION - #include + // This class is extended locally (web mirror/P4 routing/BLE state). Include + // the matching project header explicitly: the MeshCore dependency ships an + // older class layout, and mixing that header with our local .cpp makes the + // placement allocation undersized and shifts every member after _ws_started. + #include "helpers/esp32/MultiTransportCompanionInterface.h" #include "helpers/esp32/MqttBridge.h" #include #include @@ -88,6 +92,23 @@ static uint32_t _atoi(const char* sp) { #ifndef WS_PORT #define WS_PORT 8765 #endif + + #if defined(TLORA_PAGER) + static void pagerLogInternalHeap(const char* phase) { + const uint32_t caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; + Serial.printf("[heap] %s: internal free=%u largest=%u low=%u dma=%u psram=%u\n", + phase, + (unsigned)heap_caps_get_free_size(caps), + (unsigned)heap_caps_get_largest_free_block(caps), + (unsigned)heap_caps_get_minimum_free_size(caps), + (unsigned)heap_caps_get_free_size(MALLOC_CAP_DMA), + (unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); + } + // On a fresh profile there may be no saved credentials yet. + // Claim Wi-Fi's coexistence resources first, then start the prepared BLE + // stack as soon as that first association succeeds. + static bool s_pager_ble_after_wifi = false; + #endif #elif defined(WIFI_SSID) #include SerialWifiInterface serial_interface; @@ -677,16 +698,13 @@ void setup() { serial_interface.begin(Serial, TCP_PORT, WS_PORT); Serial.println("[BOOT] serial_interface ok"); serial_interface.setBroadcastResponses(true); // RX log, channel messages, etc. go to all clients (USB + TCP + WS [+ BLE]), not only last sender - /* Pick BLE vs WiFi at boot. The ESP32-S3 doesn't have enough internal heap - * (esp_wifi_init needs ~50KB for DMA buffers) to run Bluedroid BLE + - * LVGL/TFT + WiFi all at once — esp_wifi_init silently returns ESP_ERR_NO_MEM, - * leaving WiFi.getMode() at WIFI_MODE_NULL. So we mutex them: if the user - * has saved WiFi credentials AND the radio is enabled, skip BLE init and - * use WiFi exclusively. Otherwise init BLE. Toggle by saving/clearing creds - * + reboot (saveWifiCb auto-restarts). On the touch build the user can also - * pick Wi-Fi with no creds yet (to scan/configure on-device) — wantsWifi() - * returns true for that case so the radio comes up scannable. */ + /* Wi-Fi and NimBLE coexist, but their first allocations are order-sensitive + * on the Pager. Claim Wi-Fi's DMA/coexistence resources before starting BLE; + * T-Deck does not reproduce this sequencing constraint. */ bool want_wifi = wifiConfigWantsWifi(); +#if defined(TLORA_PAGER) + bool pager_wifi_ready_for_ble = false; +#endif /* Wi-Fi + BLE now COEXIST (NimBLE host is light enough — the old Bluedroid * heap clash is gone). Bring Wi-Fi up FIRST: esp_wifi_init grabs a big * contiguous DMA block, so let it claim memory before BLE. (Association @@ -701,6 +719,32 @@ void setup() { // setup wizard, no creds yet) DTIM modem-sleep naps the radio through the // scan dwell, so WiFi.scanNetworks() comes back empty ("no networks found"). // It's enabled once we actually associate — see the GOT_IP handler below. +#if defined(TLORA_PAGER) + /* Arduino-ESP32 2.0.17 can watchdog in WPA3 SAE on the Pager when NimBLE + * already owns the coexistence path. Start the saved Wi-Fi association + * first and wait on the state transition (not a fixed delay), while + * internal heap is still plentiful; BLE starts below only after the link + * is established. T-Deck does not reproduce this ordering constraint. */ + if (wifiConfigHasRuntime()) { + char ssid[WIFI_CONFIG_SSID_MAX]; + char pwd[WIFI_CONFIG_PWD_MAX]; + wifiConfigGetSsid(ssid, sizeof(ssid)); + wifiConfigGetPwd(pwd, sizeof(pwd)); + if (ssid[0]) { + const uint32_t assoc_start_ms = millis(); + WiFi.begin(ssid, pwd[0] ? pwd : nullptr); + while (WiFi.status() != WL_CONNECTED && + (uint32_t)(millis() - assoc_start_ms) < 12000UL) { + delay(20); + } + pager_wifi_ready_for_ble = WiFi.status() == WL_CONNECTED; + Serial.printf("[boot] Pager Wi-Fi pre-BLE association: %s (%lums)\n", + pager_wifi_ready_for_ble ? "ready" : "deferred", + (unsigned long)(millis() - assoc_start_ms)); + pagerLogInternalHeap("after Wi-Fi association"); + } + } +#endif } #if defined(BLE_PIN_CODE) /* Always stash the BLE params so the toggle can bring BLE up live later, even @@ -715,6 +759,35 @@ void setup() { { NodePrefs* _np = the_mesh.getNodePrefs(); _np->node_name[sizeof(_np->node_name) - 1] = '\0'; } serial_interface.prepareBle(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin()); +#if defined(TLORA_PAGER) + { + const bool want_ble = wifiConfigGetBleEnabled(); + /* Serialise the Pager's first association before BLE init. Once associated, + * the two radios coexist normally. When Wi-Fi is enabled, pre-create the + * stack even if the saved BLE state is off: late allocation after LVGL has + * built the home UI can no longer find a large enough internal block, while + * a pre-created disabled stack can start advertising allocation-free. */ + if (!want_wifi || pager_wifi_ready_for_ble || !wifiConfigHasRuntime()) { + if (want_ble || want_wifi) { + serial_interface.beginBle(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, + the_mesh.getBLEPin(), want_ble); + Serial.printf("[boot] BLE stack ready (enabled=%d wifi=%d)\n", + (int)want_ble, (int)want_wifi); + pagerLogInternalHeap("after BLE init"); + } + } else { + // Wi-Fi owns its required heap now, but a saved association did not + // complete. Still allocate the NimBLE/GATT objects before LVGL consumes + // and fragments the remaining internal RAM; leave advertising disabled + // until Wi-Fi connects (or the user explicitly enables BLE live). + serial_interface.beginBle(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, + the_mesh.getBLEPin(), false); + s_pager_ble_after_wifi = want_ble; + Serial.printf("[boot] BLE stack ready disabled; deferred enable=%d\n", (int)want_ble); + pagerLogInternalHeap("after deferred BLE init"); + } + } +#else if (wifiConfigGetBleEnabled()) { const size_t BLE_COEXIST_MIN_FREE = 50 * 1024; // free heap after Wi-Fi to also start BLE const size_t BLE_COEXIST_MIN_BLOCK = 20 * 1024; // largest contiguous block (NimBLE controller/host) @@ -728,6 +801,7 @@ void setup() { } } #endif +#endif #elif defined(WIFI_SSID) board.setInhibitSleep(true); // prevent sleep when WiFi is active WiFi.setAutoReconnect(true); @@ -798,6 +872,9 @@ void setup() { #ifdef DISPLAY_CLASS ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); // still want to pass this in as dependency, as prefs might be moved Serial.println("[BOOT] ui ready"); +#if defined(TLORA_PAGER) && defined(MULTI_TRANSPORT_COMPANION) + pagerLogInternalHeap("after UI init"); +#endif #endif board.onBootComplete(); @@ -826,12 +903,9 @@ void loop() { static const uint32_t WIFI_RETRY_INTERVAL_MS = 10000; static bool wifi_radio_prev = true; static bool wifi_radio_inited = false; - /* BLE-vs-WiFi mutex (chosen at setup based on saved creds + radio_en pref): - * if BLE was initialized, do NOT attempt to bring WiFi up here — esp_wifi_init - * would fail with ESP_ERR_NO_MEM after Bluedroid grabbed the internal heap, - * and the resulting OOM cascade freezes LVGL. Only run the WiFi state - * machine if creds are saved AND the radio pref is on, mirroring `want_wifi` - * in setup(). (Touch may also want Wi-Fi up with no creds, to scan.) */ + /* Run the saved Wi-Fi state machine whenever its radio preference is on. + * Pager setup separately guarantees that Wi-Fi claims its coexistence + * resources before a cold BLE start. */ bool wifi_radio_en = wifiConfigWantsWifi(); if (!wifi_radio_inited) { wifi_radio_inited = true; @@ -842,6 +916,15 @@ void loop() { WiFi.disconnect(true); delay(50); WiFi.mode(WIFI_OFF); +#if defined(TLORA_PAGER) && defined(BLE_PIN_CODE) + if (s_pager_ble_after_wifi) { + s_pager_ble_after_wifi = false; + if (wifiConfigGetBleEnabled() && !serial_interface.isBleEnabled()) { + serial_interface.enableBle(); + Serial.println("[boot] deferred BLE enabled after T-Pager Wi-Fi was disabled"); + } + } +#endif } wifi_started = false; } @@ -877,10 +960,10 @@ void loop() { char pwd[WIFI_CONFIG_PWD_MAX]; wifiConfigGetSsid(ssid, sizeof(ssid)); wifiConfigGetPwd(pwd, sizeof(pwd)); - if (strlen(ssid) > 0) { + if (strlen(ssid) > 0 && WiFi.status() != WL_CONNECTED) { WiFi.begin(ssid, pwd[0] ? pwd : nullptr); - last_wifi_retry_ms = millis(); } + last_wifi_retry_ms = millis(); } } // Automatic WiFi recovery for TCP mode: retry connection periodically if link drops. @@ -909,6 +992,15 @@ void loop() { static bool sntp_pushed = false; static uint32_t sntp_kick_ms = 0; if (WiFi.status() == WL_CONNECTED) { +#if defined(TLORA_PAGER) && defined(BLE_PIN_CODE) + if (s_pager_ble_after_wifi) { + s_pager_ble_after_wifi = false; + if (wifiConfigGetBleEnabled() && !serial_interface.isBleEnabled()) { + serial_interface.enableBle(); + Serial.println("[boot] deferred BLE enabled after T-Pager Wi-Fi association"); + } + } +#endif // Now that we're associated, enable DTIM modem-sleep (saves power + gives // BLE coexistence airtime). Deferred to here on purpose: enabling it on the // unassociated STA naps the radio through a scan dwell and breaks the setup diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index 510f0c0..c1f01ef 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -1993,11 +1993,11 @@ static bool discoveredSweepHops() { static bool g_cap_touch_hw_started = false; // ---- LVGL draw buffer ---- -// 240x24 RGB565 = 11,520 bytes. Allocated in PSRAM at UITask::begin() so the -// internal DRAM stays free for WiFi DMA buffers (esp_wifi_init needs ~50 KB -// of DRAM; with the buffer + LVGL widgets in DRAM the device was OOM'ing as -// soon as WiFi came up). PSRAM is slower than DRAM but the Adafruit ST7789 -// SPI driver reads the buffer linearly which the cache handles well. +// 240x24 RGB565 = 11,520 bytes. The T-Pager keeps this in PSRAM so Wi-Fi + +// NimBLE connection/security allocations retain enough contiguous internal +// DRAM. Its ST7796 flush is synchronous TFT_eSPI::pushColors (no DMA), so the +// external-RAM source is valid. Other boards retain the faster internal-DMA +// allocation below unless it fails. // // 1.5x the original 240x16 size — modest bump to reduce setAddrWindow // round-trips without giving LVGL more headroom to over-invalidate. @@ -12987,15 +12987,15 @@ static void showConfirm(const char* msg, const char* ok_label, SimpleCb on_confi } // ----- Bluetooth settings page ----- -// Wi-Fi + BLE coexist now (NimBLE's host is light enough to share the ESP32-S3 -// internal heap with esp_wifi + LVGL), so this is a plain LIVE toggle of the BLE -// radio — no reboot, and Wi-Fi is left untouched. State is persisted (ble_en). +// Wi-Fi + BLE coexist once both stacks are allocated. Re-enabling a resident +// stack is live; a cold Pager allocation restarts through setup's Wi-Fi-first +// ordering. State is persisted (ble_en), and Wi-Fi is left enabled. // The pairing code is editable here (persisted to _prefs.ble_pin; applied at the // next boot, since the passkey is baked into serial_interface.begin()). static lv_obj_t* s_ble_pin_ta = nullptr; // editable 6-digit pairing-code field on the BLE page #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) -// Bluetooth enable switch: instant toggle (BLE on/off is live — enableBle() lazily brings -// NimBLE up; no Save button, no reboot). +// Bluetooth enable switch: resident BLE toggles live; enableBle() handles the +// Pager's ordered-restart fallback for a cold stack. No Save button is needed. static void bleEnableSwitchCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED || !g_lv.task) return; if (!g_lv.task->hasBleCapability()) { g_lv.task->showAlert(TR("No Bluetooth on this device"), 1400); return; } @@ -13400,28 +13400,66 @@ static void wifiScanOpenAndKick() { // Mirror of the BLE-enable guard: bringing esp_wifi up needs ~50 KB free internal // heap, and with BLE already holding its share on a tight board the init fails -// DEEP in the Wi-Fi state machine (main.cpp) where nothing reports it — the UI -// toasted "Wi-Fi on" while the radio never came up. Refuse at the toggle instead, -// symmetrically with UITask::enableBle(). Thresholds match the boot co-init guard. -static bool wifiEnableGuardOk() { +// DEEP in the Wi-Fi state machine (main.cpp) where nothing reports it. Keep the +// decision pure so every caller can distinguish a Pager's ordered restart from +// a real low-memory refusal. +enum class WifiEnableGate : uint8_t { Ready, RestartRequired, LowMemory }; + +static WifiEnableGate wifiEnableGate() { #if defined(ESP32) - return ESP.getFreeHeap() >= 50u * 1024u && ESP.getMaxAllocHeap() >= 20u * 1024u; + // Once esp_wifi is initialized, a live off/on toggle does not need its large + // one-time DMA allocation. Apply the reserve only to a cold start. + if (WiFi.getMode() != WIFI_MODE_NULL) return WifiEnableGate::Ready; +#if defined(TLORA_PAGER) + // Pager coexistence is reliable only when setup claims Wi-Fi before NimBLE + // and before LVGL. Always route a genuinely cold Wi-Fi start through that + // ordering, even if the current heap happens to look large enough. + return WifiEnableGate::RestartRequired; #else - return true; + const uint32_t internal_caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; + const size_t freeh = heap_caps_get_free_size(internal_caps); + const size_t maxblk = heap_caps_get_largest_free_block(internal_caps); + if (freeh >= 50u * 1024u && maxblk >= 20u * 1024u) return WifiEnableGate::Ready; + Serial.printf("[wifi] cold start refused: free=%u maxblk=%u\n", + (unsigned)freeh, (unsigned)maxblk); +#endif + return WifiEnableGate::LowMemory; +#else + return WifiEnableGate::Ready; #endif } +static bool wifiPrepareEnable(lv_obj_t* switch_to_revert = nullptr) { + const WifiEnableGate gate = wifiEnableGate(); + if (gate == WifiEnableGate::Ready) return true; + if (gate == WifiEnableGate::LowMemory) { + if (switch_to_revert) lv_obj_clear_state(switch_to_revert, LV_STATE_CHECKED); + if (g_lv.task) + g_lv.task->showAlert(TR("Not enough free memory for Wi-Fi. Turn Bluetooth off first."), 2200); + return false; + } + + // Persist the requested state, paint an honest status, then reboot through + // the normal flush path so setup can claim Wi-Fi before NimBLE and LVGL. + wifiConfigSetRadioEnabled(true); + if (g_lv.task) { + g_lv.task->showAlert(TR("Restarting to enable Wi-Fi"), 800); + lv_refr_now(NULL); + g_lv.task->rebootDevice(); + } else { + ESP.restart(); + } + return false; +} + // "Scan" button -> open the popup + queue a scan on the core-0 worker. If Wi-Fi -// is off (BLE active), bring the radio up LIVE first — it coexists with NimBLE, -// no reboot — then scan; the worker brings STA up and lists networks. +// is off, prepare the radio first (a cold Pager needs the ordered restart above; +// other live stacks can continue immediately), then let the worker list networks. static void wifiScanStartCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; #if defined(MULTI_TRANSPORT_COMPANION) if (!wifiConfigWantsWifi()) { - if (!wifiEnableGuardOk()) { - if (g_lv.task) g_lv.task->showAlert(TR("Not enough free memory for Wi-Fi. Turn Bluetooth off first."), 2200); - return; - } + if (!wifiPrepareEnable()) return; wifiConfigSetRadioEnabled(true); // wantsWifi() now true -> the scan worker can bring STA up wifiConfigRequestApply(); // main loop brings esp_wifi up live (no reboot) if (g_lv.task) g_lv.task->showAlert(TR("Wi-Fi on, scanning\xE2\x80\xA6"), 1200); @@ -13518,18 +13556,14 @@ static void wifiScanService() { #endif } -// Live Wi-Fi radio toggle on the Wi-Fi settings page (mirrors the control-center -// toggle). wifiConfigSetRadioEnabled persists the pref + flags an apply, so the -// main loop brings esp_wifi up / down on the spot — no Save needed, no reboot. +// Wi-Fi radio toggle on the Wi-Fi settings page (mirrors the control-center +// toggle). Existing stacks apply live; a cold Pager routes through the ordered +// restart above. No separate Save action is needed. static void wifiRadioToggleCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED) return; #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED); - if (on && !wifiEnableGuardOk()) { - lv_obj_clear_state(lv_event_get_target(e), LV_STATE_CHECKED); // revert the switch - if (g_lv.task) g_lv.task->showAlert(TR("Not enough free memory for Wi-Fi. Turn Bluetooth off first."), 2200); - return; - } + if (on && !wifiPrepareEnable(lv_event_get_target(e))) return; wifiConfigSetRadioEnabled(on); if (g_lv.task) g_lv.task->showAlert(on ? TR("Wi-Fi on") : TR("Wi-Fi off"), 800); refreshStatusLabels(); @@ -36122,14 +36156,10 @@ static void toggleControlCenter() { if (s_cc_root) closeControlCenter(); else op static void ccWifiCb(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_CLICKED) return; #if defined(ESP32) - // Live: the main loop brings esp_wifi up (WiFi.mode/begin) or down (WIFI_OFF) - // in response to this pref — no reboot. + // Existing stacks apply live in the main loop; a cold Pager takes the ordered + // restart path so Wi-Fi claims its allocations before NimBLE and LVGL. const bool on = wifiConfigGetRadioEnabled(); - if (!on && !wifiEnableGuardOk()) { - if (g_lv.task) g_lv.task->showAlert(TR("Not enough free memory for Wi-Fi. Turn Bluetooth off first."), 2200); - openControlCenter(); - return; - } + if (!on && !wifiPrepareEnable()) return; wifiConfigSetRadioEnabled(!on); if (g_lv.task) g_lv.task->showAlert(on ? TR("Wi-Fi off") : TR("Wi-Fi on"), 800); openControlCenter(); @@ -46148,17 +46178,25 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no const int draw_band_w = 240; #endif const size_t buf_bytes = sizeof(lv_color_t) * draw_band_w * LV_DRAW_BUF_LINES; - // Internal DMA-capable DRAM — this is the hot loop's read source - // during SPI flush. PSRAM (~80 MHz QSPI) is ~3× slower than - // internal SRAM. INTERNAL|DMA also makes it eligible for SPI DMA - // transfers if the display driver ever grows them. Fall back to - // PSRAM if internal DRAM is too fragmented at boot. + // Internal DMA-capable DRAM — this is the hot loop's read source during + // SPI flush. The T-Pager is deliberately the exception: ST7796LCDDisplay + // uses synchronous pushColors (not DMA), while BLE needs this contiguous + // internal block later when a client connects and negotiates security. +#if defined(TLORA_PAGER) + g_draw_buffer = (lv_color_t*)heap_caps_malloc( + buf_bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!g_draw_buffer) { + g_draw_buffer = (lv_color_t*)heap_caps_malloc( + buf_bytes, MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA | MALLOC_CAP_8BIT); + } +#else g_draw_buffer = (lv_color_t*)heap_caps_malloc( buf_bytes, MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA | MALLOC_CAP_8BIT); if (!g_draw_buffer) { g_draw_buffer = (lv_color_t*)heap_caps_malloc( buf_bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); } +#endif if (!g_draw_buffer) g_draw_buffer = (lv_color_t*)malloc(buf_bytes); // Last-ditch under severe DRAM pressure — e.g. a unit whose PSRAM didn't // init (some T-Deck clones are QSPI, not the expected OPI), so even SPIRAM @@ -47549,15 +47587,20 @@ static bool uiHistWaitWorkerIdle() { bool UITask::enableBle() { if (!_serial) return false; -#if defined(ESP32) - // Same thresholds as the boot-time co-init guard (main.cpp) — keep in sync. - const size_t BLE_COEXIST_MIN_FREE = 50u * 1024u; - const size_t BLE_COEXIST_MIN_BLOCK = 20u * 1024u; - if (ESP.getFreeHeap() < BLE_COEXIST_MIN_FREE || ESP.getMaxAllocHeap() < BLE_COEXIST_MIN_BLOCK) - return false; -#endif _serial->enableBle(); - return true; + // The concrete transport applies the heap guard only when it must cold-start + // NimBLE. A resident disabled stack can always be re-enabled allocation-free. + if (_serial->isBleEnabled()) return true; +#if defined(TLORA_PAGER) + // Same cold-start contract as Wi-Fi above. This is not an OOM failure the + // user can repair by toggling the other radio: remember the requested state + // and allocate NimBLE during the next ordered boot, before LVGL fragments + // internal DRAM. rebootDevice() flushes pending history before ESP.restart(). + wifiConfigSetBleEnabled(true); + showAlert("Restarting to enable Bluetooth", 800); + rebootDevice(); +#endif + return false; } void UITask::persistHistoryNow() { diff --git a/src/ui-touch/UITask.h b/src/ui-touch/UITask.h index e7acb2c..67ae235 100644 --- a/src/ui-touch/UITask.h +++ b/src/ui-touch/UITask.h @@ -491,11 +491,9 @@ public: void disableTcp() { if (_serial) _serial->disableTcp(); } bool hasBleCapability() const { return _serial && _serial->hasBleCapability(); } bool isBleEnabled() const { return _serial && _serial->isBleEnabled(); } - // Live BLE enable, guarded like the boot co-init in main.cpp: NimBLE needs - // ~50 KB free internal heap + a 20 KB contiguous block, and starting it below - // that does not fail cleanly — it panics mid-init (the "reboots when I turn - // BLE on with Wi-Fi running" report). Returns false when refused; the caller - // shows the reason and reverts its switch. Defined in UITask.cpp. + // Live BLE enable. The concrete transport applies its heap guard only for a + // cold NimBLE allocation; re-enabling a pre-created stack is allocation-free. + // Returns false when a required cold start cannot be made safely. bool enableBle(); void disableBle() { if (_serial) _serial->disableBle(); } int getWsConnectedCount() const { return _serial ? _serial->getWsConnectedCount() : 0; }