From d5b79cf0b4ba2ade9ef983c3758590fcd76e8914 Mon Sep 17 00:00:00 2001
From: pelgraine <140762863+pelgraine@users.noreply.github.com>
Date: Tue, 24 Feb 2026 02:17:42 +1100
Subject: [PATCH] fix ble error loop crash in serialbleinterface and main; same
ble crash fix in webreaderscreen
---
examples/companion_radio/main.cpp | 11 +-
.../companion_radio/ui-new/Webreaderscreen.h | 102 ++++++++++++++----
src/helpers/esp32/SerialBLEInterface.cpp | 14 ++-
3 files changed, 102 insertions(+), 25 deletions(-)
diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp
index f30a7e6..4aa1f1a 100644
--- a/examples/companion_radio/main.cpp
+++ b/examples/companion_radio/main.cpp
@@ -1400,11 +1400,18 @@ void handleKeyboardInput() {
Serial.printf("WebReader: heap BEFORE BT release: free=%d, largest=%d\n",
ESP.getFreeHeap(), ESP.getMaxAllocHeap());
- // 1) Stop BLE controller (disable + deinit)
+ // 1) Gracefully disable BLE interface (stop advertising, disconnect, clear queues)
+ // Must happen BEFORE btStop() while BLE objects are still valid
+ #ifdef BLE_PIN_CODE
+ serial_interface.disable();
+ delay(50);
+ #endif
+
+ // 2) Stop BLE controller (disable + deinit)
btStop();
delay(50);
- // 2) Release the BT controller's reserved memory region back to heap
+ // 3) Release the BT controller's reserved memory region back to heap
esp_bt_controller_mem_release(ESP_BT_MODE_BTDM);
delay(50);
diff --git a/examples/companion_radio/ui-new/Webreaderscreen.h b/examples/companion_radio/ui-new/Webreaderscreen.h
index c097f5f..f39c78d 100644
--- a/examples/companion_radio/ui-new/Webreaderscreen.h
+++ b/examples/companion_radio/ui-new/Webreaderscreen.h
@@ -442,6 +442,7 @@ inline ParseResult parseHtml(const char* html, int htmlLen,
char pendingLabel[48] = {0};
bool inLabel = false;
int labelTextStart = 0;
+ int listDepth = 0; // Nesting depth of
// (for smart
- formatting)
// Find tag to skip section
for (int i = 0; i < htmlLen - 6; i++) {
@@ -533,26 +534,50 @@ inline ParseResult parseHtml(const char* html, int htmlLen,
}
}
+ // Track
// nesting depth for smart
- formatting
+ if (tagNameLen == 2) {
+ bool isList = (tagName[0] == 'u' && tagName[1] == 'l') ||
+ (tagName[0] == 'o' && tagName[1] == 'l') ||
+ (tagName[0] == 'd' && tagName[1] == 'l');
+ if (isList) {
+ if (isClosing) { if (listDepth > 0) listDepth--; }
+ else listDepth++;
+ }
+ }
+
// Handle
- opening: ensure line break before heading
if (!isClosing && tagNameLen == 2 && tagName[0] == 'h' &&
tagName[1] >= '1' && tagName[1] <= '6') {
- if (ti < textMax - 2) {
+ if (ti < textMax - 12) {
if (!lastWasBreak && ti > 0) {
textOut[ti++] = '\n';
}
+ // Separator line before h4 headings (work titles on listing pages)
+ if (tagName[1] == '4' && ti > 1) {
+ const char* sep = "--------";
+ for (int s = 0; sep[s]; s++) textOut[ti++] = sep[s];
+ textOut[ti++] = '\n';
+ }
// Double break before h1/h2 for visual separation
if (tagName[1] <= '2' && ti > 0 && ti < textMax - 1) {
textOut[ti++] = '\n';
}
+ // Heading markers for h1-h4 (renderer draws underline)
+ if (tagName[1] <= '4') {
+ textOut[ti++] = '\x01';
+ }
lastWasBreak = true;
lastWasSpace = false;
}
}
- // Handle closing
-: line break after heading
+ // Handle closing -: heading end marker + line break
if (isClosing && tagNameLen == 2 && tagName[0] == 'h' &&
tagName[1] >= '1' && tagName[1] <= '6') {
- if (ti < textMax - 1) {
+ if (ti < textMax - 2) {
+ if (tagName[1] <= '4') {
+ textOut[ti++] = '\x02'; // Heading end marker
+ }
textOut[ti++] = '\n';
lastWasBreak = true;
lastWasSpace = false;
@@ -607,18 +632,15 @@ inline ParseResult parseHtml(const char* html, int htmlLen,
currentHref[0] = '\0';
}
- // Handle - - comma-separated inline flow (compact for tag lists, pagination)
+ // Handle
- - always comma-separated inline flow
if (!isClosing && tagNameLen == 2 && tagName[0] == 'l' && tagName[1] == 'i') {
if (ti < textMax - 3) {
- // Trim trailing whitespace from buffer (between
and - )
while (ti > 0 && textOut[ti-1] == ' ') ti--;
- // Add comma separator if continuing from a previous item
if (ti > 0 && textOut[ti-1] != '\n' && textOut[ti-1] != ',') {
textOut[ti++] = ',';
textOut[ti++] = ' ';
lastWasSpace = true;
} else if (ti > 0 && textOut[ti-1] == ',') {
- // Previous item was empty — comma exists, just add space
textOut[ti++] = ' ';
lastWasSpace = true;
}
@@ -1066,7 +1088,7 @@ private:
unsigned long _toastTime; // millis() when toast was set
// Forms
- WebForm _forms[WEB_MAX_FORMS];
+ WebForm* _forms; // PSRAM allocated
int _formCount;
int _activeForm; // Which form is being filled (-1 = none)
int _activeField; // Which field in the active form (index into visible fields)
@@ -1082,7 +1104,7 @@ private:
char name[64];
char value[512]; // AO3 session cookies are 300+ chars of base64
};
- Cookie _cookies[WEB_MAX_COOKIES];
+ Cookie* _cookies; // PSRAM allocated
int _cookieCount;
// Fetch state
@@ -1841,12 +1863,10 @@ private:
// Connection-level failure (timeout, TLS error, etc) — retry once
if (httpCode < 0 && connRetries < 1) {
http.end();
- // Destroy and recreate TLS client — old connection state is broken
- delete tlsClient;
- tlsClient = new WiFiClientSecure();
- tlsClient->setInsecure();
- tlsClient->setHandshakeTimeout(30);
- lastHost = ""; // Force fresh connection
+ // Reset TLS client — stop connection but keep the object alive
+ // (HTTPClient destructor will access it when 'http' goes out of scope)
+ tlsClient->stop();
+ lastHost = ""; // Force fresh connection on next begin()
connRetries++;
redirectCount++;
Serial.printf("WebReader: Connection error %d, retrying...\n", httpCode);
@@ -2610,6 +2630,13 @@ private:
int pos = pageStart;
int maxY = display.height() - _footerHeight - _lineHeight;
+ // Check if page starts inside a heading (marker from previous page)
+ bool inHeading = false;
+ for (int scan = 0; scan < pageStart && scan < _textLen; scan++) {
+ if (_textBuffer[scan] == '\x01') inHeading = true;
+ if (_textBuffer[scan] == '\x02') inHeading = false;
+ }
+
while (pos < pageEnd && lineCount < _linesPerPage && y <= maxY) {
int oldPos = pos;
WebWrapResult wrap = webFindLineBreak(_textBuffer, pageEnd, pos, _charsPerLine);
@@ -2618,12 +2645,25 @@ private:
display.setCursor(0, y);
+ // Check if this line contains heading text (for underline)
+ bool lineHasHeading = inHeading;
+ if (!lineHasHeading) {
+ for (int scan = pos; scan < wrap.lineEnd && scan < pageEnd; scan++) {
+ if (_textBuffer[scan] == '\x01') { lineHasHeading = true; break; }
+ }
+ }
+
// Render characters with UTF-8/CP437 handling
char charStr[2] = {0, 0};
+
int j = pos;
while (j < wrap.lineEnd && j < pageEnd) {
uint8_t b = (uint8_t)_textBuffer[j];
+ // Heading markers: \x01 = start, \x02 = end
+ if (b == 0x01) { inHeading = true; j++; continue; }
+ if (b == 0x02) { inHeading = false; j++; continue; }
+
if (b < 32) { j++; continue; }
// Detect link markers [N] and render in different color
@@ -2688,6 +2728,22 @@ private:
}
}
+ // Draw underline below the last line of a heading
+ // (the line where \x02 marker ends the heading, or heading continues to next line)
+ bool headingEndsHere = false;
+ if (lineHasHeading) {
+ // Check if heading ends on this line
+ for (int scan = pos; scan < wrap.lineEnd && scan < pageEnd; scan++) {
+ if (_textBuffer[scan] == '\x02') { headingEndsHere = true; break; }
+ }
+ // Also underline if this is the last line of the page and still in heading
+ if (!headingEndsHere && wrap.nextStart >= pageEnd) headingEndsHere = true;
+ }
+ if (headingEndsHere) {
+ display.setColor(DisplayDriver::LIGHT);
+ display.drawRect(0, y + _lineHeight - 1, display.width(), 1);
+ }
+
y += _lineHeight;
lineCount++;
pos = wrap.nextStart;
@@ -2954,7 +3010,7 @@ private:
if (c == 'x' || c == 'X') {
bool hadData = (_cookieCount > 0 || !_history.empty());
_cookieCount = 0;
- memset(_cookies, 0, sizeof(_cookies));
+ memset(_cookies, 0, sizeof(Cookie) * WEB_MAX_COOKIES);
_history.clear();
saveHistory();
Serial.println("WebReader: Cookies and history cleared");
@@ -4238,8 +4294,9 @@ public:
_currentPage(0), _totalPages(0),
_homeSelected(0), _urlEditing(false),
_linkInput(0), _linkInputActive(false),
- _formCount(0), _activeForm(-1), _activeField(0),
- _formFieldEditing(false), _formEditLen(0), _formLastCharAt(0), _cookieCount(0),
+ _formCount(0), _forms(nullptr), _activeForm(-1), _activeField(0),
+ _formFieldEditing(false), _formEditLen(0), _formLastCharAt(0),
+ _cookies(nullptr), _cookieCount(0),
_fetchStartTime(0), _fetchProgress(0),
_ircClient(nullptr), _ircUseTLS(false), _ircConnected(false), _ircRegistered(false),
_ircJoined(false), _ircPort(6697), _ircMessages(nullptr),
@@ -4259,13 +4316,18 @@ public:
_ircCompose[0] = '\0';
_ircSetupBuf[0] = '\0';
_ircLineBuf[0] = '\0';
- memset(_forms, 0, sizeof(_forms));
- memset(_cookies, 0, sizeof(_cookies));
+ _toastMsg[0] = '\0';
+ _toastTime = 0;
+ // Allocate forms and cookies in PSRAM to free internal heap for TLS
+ _forms = (WebForm*)ps_calloc(WEB_MAX_FORMS, sizeof(WebForm));
+ _cookies = (Cookie*)ps_calloc(WEB_MAX_COOKIES, sizeof(Cookie));
loadIRCConfig();
}
~WebReaderScreen() {
freeBuffers();
+ if (_forms) { free(_forms); _forms = nullptr; }
+ if (_cookies) { free(_cookies); _cookies = nullptr; }
if (_ircClient) {
if (_ircClient->connected()) _ircClient->stop();
delete _ircClient;
diff --git a/src/helpers/esp32/SerialBLEInterface.cpp b/src/helpers/esp32/SerialBLEInterface.cpp
index f31256d..950ce07 100644
--- a/src/helpers/esp32/SerialBLEInterface.cpp
+++ b/src/helpers/esp32/SerialBLEInterface.cpp
@@ -147,15 +147,21 @@ void SerialBLEInterface::enable() {
}
void SerialBLEInterface::disable() {
+ bool wasEnabled = _isEnabled;
_isEnabled = false;
BLE_DEBUG_PRINTLN("SerialBLEInterface::disable");
- pServer->getAdvertising()->stop();
- pServer->disconnect(last_conn_id);
- pService->stop();
+ // Only try BLE operations if we were previously enabled
+ // (avoids accessing dead BLE objects after btStop/mem_release)
+ if (wasEnabled && pServer) {
+ pServer->getAdvertising()->stop();
+ pServer->disconnect(last_conn_id);
+ pService->stop();
+ }
oldDeviceConnected = deviceConnected = false;
adv_restart_time = 0;
+ clearBuffers();
}
size_t SerialBLEInterface::writeFrame(const uint8_t src[], size_t len) {
@@ -186,6 +192,8 @@ bool SerialBLEInterface::isWriteBusy() const {
}
size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) {
+ if (!_isEnabled) return 0; // BLE disabled — skip all BLE operations
+
if (send_queue_len > 0 // first, check send queue
&& millis() >= _last_write + BLE_WRITE_MIN_INTERVAL // space the writes apart
) {