fix(tpager): stabilize Wi-Fi and BLE coexistence

Signed-off-by: Pixel Perfect <me@pixp.cc>
This commit is contained in:
Pixel Perfect committed 2026-08-05 17:56:10 -07:00
1 parent ad6446b312
commit cadfa35cb5
6 files changed
+279 -92

No files matched your search

+110 -18
View File
@@ -65,7 +65,11 @@ static uint32_t _atoi(const char* sp) {
#ifdef ESP32
#ifdef MULTI_TRANSPORT_COMPANION
#include <helpers/esp32/MultiTransportCompanionInterface.h>
// 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 <esp_heap_caps.h>
#include <new>
@@ -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 <helpers/esp32/SerialWifiInterface.h>
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