fix: webserver stability, memory leaks, epub underlining, flash screen
Webserver: - Remove MD5 hash computation from file listings (caused EAGAIN errors) - Implement JSON batching (2KB) with pacing for file listings - Simplify sendContentSafe() flow control Memory: - Cache QR codes on server start instead of regenerating per render - Optimize WiFi scan: vector deduplication, 20 network limit, early scanDelete() - Fix 48KB cover buffer leak when navigating Home -> File Transfer EPUB Reader: - Fix errant underlining by flushing partWordBuffer before style changes - Text before styled inline elements (e.g., <a> with CSS underline) no longer incorrectly receives the element's styling Flash Screen: - Fix version string buffer overflow (30 -> 50 char limit) - Use half refresh for cleaner display - Adjust pre_flash.py timing for half refresh completion
This commit is contained in:
parent
48267ad848
commit
5464d9de3a
@ -6,6 +6,45 @@ Base: CrossPoint Reader 0.15.0
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ef-1.0.5
|
||||||
|
|
||||||
|
**Stability & Memory Improvements**
|
||||||
|
|
||||||
|
### Bug Fixes - Webserver
|
||||||
|
|
||||||
|
- **File Transfer Stability**: Removed blocking MD5 hash computation from file listings that caused EAGAIN errors and connection stalls
|
||||||
|
- **JSON Batching**: Implemented 2KB batch streaming for file listings with pacing to prevent TCP buffer overflow
|
||||||
|
- **Simplified Flow Control**: Removed unnecessary yield/delay logic from content streaming
|
||||||
|
|
||||||
|
### Bug Fixes - Memory
|
||||||
|
|
||||||
|
- **QR Code Caching**: Generate QR codes once on server start instead of regenerating on each screen render
|
||||||
|
- **WiFi Scan Optimization**: Replaced memory-heavy `std::map` deduplication with in-place vector search, limited results to 20 networks, earlier `WiFi.scanDelete()` for faster memory recovery
|
||||||
|
- **Cover Buffer Leak**: Fixed 48KB memory leak when navigating from Home to File Transfer (cover buffer now explicitly freed)
|
||||||
|
|
||||||
|
### Bug Fixes - EPUB Reader
|
||||||
|
|
||||||
|
- **Errant Underlining**: Fixed words before styled inline elements (like `<a>` tags with CSS underline) incorrectly receiving the element's style by flushing the text buffer before style changes
|
||||||
|
|
||||||
|
### Bug Fixes - Flashing Screen
|
||||||
|
|
||||||
|
- **Version String Overflow**: Fixed flash notification parsing failing on longer version strings (buffer limit increased from 30 to 50 characters)
|
||||||
|
- **Display Quality**: Changed flashing screen to half refresh for cleaner appearance
|
||||||
|
- **Timing**: Adjusted pre-flash script timing for half refresh completion
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
|
||||||
|
- `src/main.cpp` - flash screen fixes, cover buffer free on File Transfer entry
|
||||||
|
- `scripts/pre_flash.py` - timing adjustments for full refresh
|
||||||
|
- `src/network/CrossPointWebServer.cpp` - JSON batching, removed MD5 from listings
|
||||||
|
- `src/network/CrossPointWebServer.h` - removed md5 from FileInfo, simplified sendContentSafe
|
||||||
|
- `src/activities/network/CrossPointWebServerActivity.cpp` - QR code caching
|
||||||
|
- `src/activities/network/CrossPointWebServerActivity.h` - QR code cache members
|
||||||
|
- `src/activities/network/WifiSelectionActivity.cpp` - WiFi scan memory optimization
|
||||||
|
- `lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp` - flush buffer before style changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ef-1.0.4
|
## ef-1.0.4
|
||||||
|
|
||||||
**EPUB Rendering & Stability**
|
**EPUB Rendering & Stability**
|
||||||
|
|||||||
@ -485,6 +485,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) {
|
} else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) {
|
||||||
|
// Flush buffer with CURRENT style before changing effective style
|
||||||
|
self->flushPartWordBuffer();
|
||||||
|
|
||||||
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
|
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
|
||||||
// Push inline style entry for underline tag
|
// Push inline style entry for underline tag
|
||||||
StyleStackEntry entry;
|
StyleStackEntry entry;
|
||||||
@ -502,6 +505,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
self->inlineStyleStack.push_back(entry);
|
self->inlineStyleStack.push_back(entry);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
|
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
|
||||||
|
// Flush buffer with CURRENT style before changing effective style
|
||||||
|
self->flushPartWordBuffer();
|
||||||
|
|
||||||
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
|
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
|
||||||
// Push inline style entry for bold tag
|
// Push inline style entry for bold tag
|
||||||
StyleStackEntry entry;
|
StyleStackEntry entry;
|
||||||
@ -519,6 +525,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
self->inlineStyleStack.push_back(entry);
|
self->inlineStyleStack.push_back(entry);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
} else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) {
|
} else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) {
|
||||||
|
// Flush buffer with CURRENT style before changing effective style
|
||||||
|
self->flushPartWordBuffer();
|
||||||
|
|
||||||
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
|
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
|
||||||
// Push inline style entry for italic tag
|
// Push inline style entry for italic tag
|
||||||
StyleStackEntry entry;
|
StyleStackEntry entry;
|
||||||
@ -538,6 +547,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
} else if (strcmp(name, "span") == 0 || !isBlockElement) {
|
} else if (strcmp(name, "span") == 0 || !isBlockElement) {
|
||||||
// Handle span and other inline elements for CSS styling
|
// Handle span and other inline elements for CSS styling
|
||||||
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) {
|
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) {
|
||||||
|
// Flush buffer with CURRENT style before changing effective style
|
||||||
|
// This prevents text accumulated before this element from getting the new style
|
||||||
|
self->flushPartWordBuffer();
|
||||||
|
|
||||||
StyleStackEntry entry;
|
StyleStackEntry entry;
|
||||||
entry.depth = self->depth; // Track depth for matching pop
|
entry.depth = self->depth; // Track depth for matching pop
|
||||||
if (cssStyle.hasFontWeight()) {
|
if (cssStyle.hasFontWeight()) {
|
||||||
@ -573,6 +586,33 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
|
|||||||
self->lastCharDataOffset = XML_GetCurrentByteIndex(self->xmlParser);
|
self->lastCharDataOffset = XML_GetCurrentByteIndex(self->xmlParser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we're inside an <li> but no text block was created yet (direct text without inner <p>),
|
||||||
|
// create a text block and add the list marker now
|
||||||
|
if (self->insideListItem && !self->listItemHasContent) {
|
||||||
|
// Apply left margin for list items
|
||||||
|
CssStyle cssStyle;
|
||||||
|
cssStyle.marginLeft = 24.0f; // Default indent (~1.5em at 16px base)
|
||||||
|
cssStyle.defined.marginLeft = 1;
|
||||||
|
|
||||||
|
BlockStyle blockStyle = createBlockStyleFromCss(cssStyle);
|
||||||
|
self->startNewTextBlock(static_cast<TextBlock::Style>(self->paragraphAlignment), blockStyle);
|
||||||
|
|
||||||
|
// Add the list marker
|
||||||
|
if (!self->listStack.empty()) {
|
||||||
|
const ListContext& ctx = self->listStack.back();
|
||||||
|
if (ctx.isOrdered) {
|
||||||
|
std::string marker = std::to_string(ctx.counter) + ". ";
|
||||||
|
self->currentTextBlock->addWord(marker, EpdFontFamily::REGULAR);
|
||||||
|
} else {
|
||||||
|
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No list context (orphan li), use bullet as fallback
|
||||||
|
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
|
||||||
|
}
|
||||||
|
self->listItemHasContent = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Determine font style from depth-based tracking and CSS effective style
|
// Determine font style from depth-based tracking and CSS effective style
|
||||||
const bool isBold = self->boldUntilDepth < self->depth || self->effectiveBold;
|
const bool isBold = self->boldUntilDepth < self->depth || self->effectiveBold;
|
||||||
const bool isItalic = self->italicUntilDepth < self->depth || self->effectiveItalic;
|
const bool isItalic = self->italicUntilDepth < self->depth || self->effectiveItalic;
|
||||||
|
|||||||
@ -5,7 +5,11 @@ This allows the firmware to display "Flashing firmware..." on the e-ink display
|
|||||||
before the actual flash begins. The e-ink retains this message throughout the
|
before the actual flash begins. The e-ink retains this message throughout the
|
||||||
flash process since it doesn't require power to maintain the display.
|
flash process since it doesn't require power to maintain the display.
|
||||||
|
|
||||||
Protocol: Sends "FLASH:version\n" where version is read from platformio.ini
|
Protocol (Plan A - Simple timing):
|
||||||
|
1. Host opens serial port and sends "FLASH:version"
|
||||||
|
2. Host keeps port open briefly for device to receive and process
|
||||||
|
3. Device displays flash screen when it receives the command
|
||||||
|
4. Host proceeds with flash
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Import("env")
|
Import("env")
|
||||||
@ -15,7 +19,7 @@ from version_utils import get_version
|
|||||||
|
|
||||||
|
|
||||||
def before_upload(source, target, env):
|
def before_upload(source, target, env):
|
||||||
"""Send FLASH command with version to device before upload begins."""
|
"""Send FLASH command to device before uploading firmware."""
|
||||||
port = env.GetProjectOption("upload_port", None)
|
port = env.GetProjectOption("upload_port", None)
|
||||||
|
|
||||||
if not port:
|
if not port:
|
||||||
@ -29,19 +33,20 @@ def before_upload(source, target, env):
|
|||||||
]
|
]
|
||||||
port = ports[0] if ports else None
|
port = ports[0] if ports else None
|
||||||
|
|
||||||
if port:
|
if not port:
|
||||||
try:
|
|
||||||
version = get_version(env)
|
|
||||||
ser = serial.Serial(port, 115200, timeout=1)
|
|
||||||
ser.write(f"FLASH:{version}\n".encode())
|
|
||||||
ser.flush()
|
|
||||||
ser.close()
|
|
||||||
time.sleep(0.8) # Wait for e-ink fast refresh (~500ms) plus margin
|
|
||||||
print(f"[pre_flash] Flash notification sent to {port} (version {version})")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[pre_flash] Notification skipped: {e}")
|
|
||||||
else:
|
|
||||||
print("[pre_flash] No serial port found, skipping notification")
|
print("[pre_flash] No serial port found, skipping notification")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
version = get_version(env)
|
||||||
|
ser = serial.Serial(port, 115200, timeout=1)
|
||||||
|
ser.write(f"FLASH:{version}\n".encode())
|
||||||
|
ser.flush()
|
||||||
|
time.sleep(4.0) # Keep port open for device to receive and complete full refresh (~2-3s)
|
||||||
|
ser.close()
|
||||||
|
print(f"[pre_flash] Flash notification sent to {port} (version {version})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[pre_flash] Notification skipped: {e}")
|
||||||
|
|
||||||
|
|
||||||
env.AddPreAction("upload", before_upload)
|
env.AddPreAction("upload", before_upload)
|
||||||
|
|||||||
@ -300,6 +300,36 @@ void CrossPointWebServerActivity::startAccessPoint() {
|
|||||||
startWebServer();
|
startWebServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CrossPointWebServerActivity::generateQRCodes() {
|
||||||
|
Serial.printf("[%lu] [WEBACT] Generating QR codes (cached)...\n", millis());
|
||||||
|
const unsigned long startTime = millis();
|
||||||
|
|
||||||
|
// Web browser URL QR code
|
||||||
|
std::string webUrl = "http://" + connectedIP + "/";
|
||||||
|
qrcode_initText(&qrWebBrowser, qrWebBrowserBuffer, 4, ECC_LOW, webUrl.c_str());
|
||||||
|
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), webUrl.c_str());
|
||||||
|
|
||||||
|
// Companion App (Files) deep link QR code
|
||||||
|
std::string filesUrl = getCompanionAppUrl();
|
||||||
|
qrcode_initText(&qrCompanionApp, qrCompanionAppBuffer, 4, ECC_LOW, filesUrl.c_str());
|
||||||
|
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), filesUrl.c_str());
|
||||||
|
|
||||||
|
// Companion App (Library) deep link QR code
|
||||||
|
std::string libraryUrl = getCompanionAppLibraryUrl();
|
||||||
|
qrcode_initText(&qrCompanionAppLibrary, qrCompanionAppLibraryBuffer, 4, ECC_LOW, libraryUrl.c_str());
|
||||||
|
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), libraryUrl.c_str());
|
||||||
|
|
||||||
|
// WiFi config QR code (for AP mode)
|
||||||
|
if (isApMode) {
|
||||||
|
std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;";
|
||||||
|
qrcode_initText(&qrWifiConfig, qrWifiConfigBuffer, 4, ECC_LOW, wifiConfig.c_str());
|
||||||
|
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), wifiConfig.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
qrCacheValid = true;
|
||||||
|
Serial.printf("[%lu] [WEBACT] QR codes cached in %lu ms\n", millis(), millis() - startTime);
|
||||||
|
}
|
||||||
|
|
||||||
void CrossPointWebServerActivity::startWebServer() {
|
void CrossPointWebServerActivity::startWebServer() {
|
||||||
Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis());
|
Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis());
|
||||||
|
|
||||||
@ -311,6 +341,9 @@ void CrossPointWebServerActivity::startWebServer() {
|
|||||||
state = WebServerActivityState::SERVER_RUNNING;
|
state = WebServerActivityState::SERVER_RUNNING;
|
||||||
Serial.printf("[%lu] [WEBACT] Web server started successfully\n", millis());
|
Serial.printf("[%lu] [WEBACT] Web server started successfully\n", millis());
|
||||||
|
|
||||||
|
// Generate and cache QR codes now that we have IP and server ports
|
||||||
|
generateQRCodes();
|
||||||
|
|
||||||
// Force an immediate render since we're transitioning from a subactivity
|
// Force an immediate render since we're transitioning from a subactivity
|
||||||
// that had its own rendering task. We need to make sure our display is shown.
|
// that had its own rendering task. We need to make sure our display is shown.
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
@ -468,23 +501,18 @@ void CrossPointWebServerActivity::render() const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw QR code at specified position with configurable pixel size per module
|
// Draw QR code from pre-computed QRCode data at specified position
|
||||||
// Returns the size of the QR code in pixels (width = height = size * pixelsPerModule)
|
// Returns the size of the QR code in pixels (width = height = size * pixelsPerModule)
|
||||||
int drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std::string& data,
|
int drawQRCodeCached(const GfxRenderer& renderer, const int x, const int y, QRCode* qrcode,
|
||||||
const uint8_t pixelsPerModule = 7) {
|
const uint8_t pixelsPerModule = 7) {
|
||||||
QRCode qrcode;
|
for (uint8_t cy = 0; cy < qrcode->size; cy++) {
|
||||||
uint8_t qrcodeBytes[qrcode_getBufferSize(4)];
|
for (uint8_t cx = 0; cx < qrcode->size; cx++) {
|
||||||
Serial.printf("[%lu] [WEBACT] QR Code (%lu): %s\n", millis(), data.length(), data.c_str());
|
if (qrcode_getModule(qrcode, cx, cy)) {
|
||||||
|
|
||||||
qrcode_initText(&qrcode, qrcodeBytes, 4, ECC_LOW, data.c_str());
|
|
||||||
for (uint8_t cy = 0; cy < qrcode.size; cy++) {
|
|
||||||
for (uint8_t cx = 0; cx < qrcode.size; cx++) {
|
|
||||||
if (qrcode_getModule(&qrcode, cx, cy)) {
|
|
||||||
renderer.fillRect(x + pixelsPerModule * cx, y + pixelsPerModule * cy, pixelsPerModule, pixelsPerModule, true);
|
renderer.fillRect(x + pixelsPerModule * cx, y + pixelsPerModule * cy, pixelsPerModule, pixelsPerModule, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return qrcode.size * pixelsPerModule;
|
return qrcode->size * pixelsPerModule;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to format bytes into human-readable sizes
|
// Helper to format bytes into human-readable sizes
|
||||||
@ -612,8 +640,7 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
|
|||||||
|
|
||||||
if (isApMode) {
|
if (isApMode) {
|
||||||
// AP mode: Show WiFi QR code on left, connection info on right
|
// AP mode: Show WiFi QR code on left, connection info on right
|
||||||
const std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;";
|
drawQRCodeCached(renderer, QR_X, QR_Y, &qrWifiConfig, QR_PX);
|
||||||
drawQRCode(renderer, QR_X, QR_Y, wifiConfig, QR_PX);
|
|
||||||
|
|
||||||
std::string ssidInfo = "Network: " + connectedSSID;
|
std::string ssidInfo = "Network: " + connectedSSID;
|
||||||
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str());
|
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str());
|
||||||
@ -635,8 +662,7 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
|
|||||||
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, hostnameUrl.c_str());
|
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, hostnameUrl.c_str());
|
||||||
} else {
|
} else {
|
||||||
// STA mode: Show URL QR code on left, connection info on right
|
// STA mode: Show URL QR code on left, connection info on right
|
||||||
std::string webUrl = "http://" + connectedIP + "/";
|
drawQRCodeCached(renderer, QR_X, QR_Y, &qrWebBrowser, QR_PX);
|
||||||
drawQRCode(renderer, QR_X, QR_Y, webUrl, QR_PX);
|
|
||||||
|
|
||||||
std::string ssidInfo = "Network: " + connectedSSID;
|
std::string ssidInfo = "Network: " + connectedSSID;
|
||||||
if (ssidInfo.length() > 35) {
|
if (ssidInfo.length() > 35) {
|
||||||
@ -650,6 +676,7 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
|
|||||||
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ipInfo.c_str());
|
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ipInfo.c_str());
|
||||||
textY += LINE_SPACING + 8;
|
textY += LINE_SPACING + 8;
|
||||||
|
|
||||||
|
std::string webUrl = "http://" + connectedIP + "/";
|
||||||
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str(), true, EpdFontFamily::BOLD);
|
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str(), true, EpdFontFamily::BOLD);
|
||||||
textY += LINE_SPACING - 4;
|
textY += LINE_SPACING - 4;
|
||||||
|
|
||||||
@ -704,12 +731,12 @@ void CrossPointWebServerActivity::renderCompanionAppScreen() const {
|
|||||||
std::string webUrl = "http://" + connectedIP + "/files";
|
std::string webUrl = "http://" + connectedIP + "/files";
|
||||||
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str());
|
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str());
|
||||||
|
|
||||||
// Draw QR code on left
|
// Draw cached QR code on left
|
||||||
const std::string appUrl = getCompanionAppUrl();
|
drawQRCodeCached(renderer, QR_X, QR_Y, &qrCompanionApp, QR_PX);
|
||||||
drawQRCode(renderer, QR_X, QR_Y, appUrl, QR_PX);
|
|
||||||
|
|
||||||
// Show deep link URL below QR code
|
// Show deep link URL below QR code
|
||||||
const int urlY = QR_Y + QR_SIZE + 10;
|
const int urlY = QR_Y + QR_SIZE + 10;
|
||||||
|
const std::string appUrl = getCompanionAppUrl();
|
||||||
renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD);
|
renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -754,11 +781,11 @@ void CrossPointWebServerActivity::renderCompanionAppLibraryScreen() const {
|
|||||||
std::string webUrl = "http://" + connectedIP + "/";
|
std::string webUrl = "http://" + connectedIP + "/";
|
||||||
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str());
|
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str());
|
||||||
|
|
||||||
// Draw QR code on left
|
// Draw cached QR code on left
|
||||||
const std::string appUrl = getCompanionAppLibraryUrl();
|
drawQRCodeCached(renderer, QR_X, QR_Y, &qrCompanionAppLibrary, QR_PX);
|
||||||
drawQRCode(renderer, QR_X, QR_Y, appUrl, QR_PX);
|
|
||||||
|
|
||||||
// Show deep link URL below QR code
|
// Show deep link URL below QR code
|
||||||
const int urlY = QR_Y + QR_SIZE + 10;
|
const int urlY = QR_Y + QR_SIZE + 10;
|
||||||
|
const std::string appUrl = getCompanionAppLibraryUrl();
|
||||||
renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD);
|
renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
#include <freertos/FreeRTOS.h>
|
#include <freertos/FreeRTOS.h>
|
||||||
#include <freertos/semphr.h>
|
#include <freertos/semphr.h>
|
||||||
#include <freertos/task.h>
|
#include <freertos/task.h>
|
||||||
|
#include <qrcode.h>
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
@ -11,6 +12,10 @@
|
|||||||
#include "activities/ActivityWithSubactivity.h"
|
#include "activities/ActivityWithSubactivity.h"
|
||||||
#include "network/CrossPointWebServer.h"
|
#include "network/CrossPointWebServer.h"
|
||||||
|
|
||||||
|
// QR code cache - version 4 QR codes (33x33 modules)
|
||||||
|
// Buffer size for version 4: qrcode_getBufferSize(4) ≈ 185 bytes
|
||||||
|
constexpr size_t QR_BUFFER_SIZE = 185;
|
||||||
|
|
||||||
// Web server activity states
|
// Web server activity states
|
||||||
enum class WebServerActivityState {
|
enum class WebServerActivityState {
|
||||||
MODE_SELECTION, // Choosing between Join Network and Create Hotspot
|
MODE_SELECTION, // Choosing between Join Network and Create Hotspot
|
||||||
@ -62,6 +67,19 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
|
|||||||
FileTransferScreen currentScreen = FileTransferScreen::COMPANION_APP_LIBRARY;
|
FileTransferScreen currentScreen = FileTransferScreen::COMPANION_APP_LIBRARY;
|
||||||
unsigned long lastStatsRefresh = 0;
|
unsigned long lastStatsRefresh = 0;
|
||||||
|
|
||||||
|
// Cached QR codes - generated once when server starts
|
||||||
|
// Avoids recomputing QR data on every render (every 30s stats refresh)
|
||||||
|
// Marked mutable since QR drawing doesn't modify logical state but qrcode_getModule takes non-const
|
||||||
|
bool qrCacheValid = false;
|
||||||
|
mutable QRCode qrWebBrowser;
|
||||||
|
mutable QRCode qrCompanionApp;
|
||||||
|
mutable QRCode qrCompanionAppLibrary;
|
||||||
|
mutable QRCode qrWifiConfig; // For AP mode WiFi connection QR
|
||||||
|
uint8_t qrWebBrowserBuffer[QR_BUFFER_SIZE];
|
||||||
|
uint8_t qrCompanionAppBuffer[QR_BUFFER_SIZE];
|
||||||
|
uint8_t qrCompanionAppLibraryBuffer[QR_BUFFER_SIZE];
|
||||||
|
uint8_t qrWifiConfigBuffer[QR_BUFFER_SIZE];
|
||||||
|
|
||||||
static void taskTrampoline(void* param);
|
static void taskTrampoline(void* param);
|
||||||
[[noreturn]] void displayTaskLoop();
|
[[noreturn]] void displayTaskLoop();
|
||||||
void render() const;
|
void render() const;
|
||||||
@ -78,6 +96,7 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
|
|||||||
void startAccessPoint();
|
void startAccessPoint();
|
||||||
void startWebServer();
|
void startWebServer();
|
||||||
void stopWebServer();
|
void stopWebServer();
|
||||||
|
void generateQRCodes();
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit CrossPointWebServerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
explicit CrossPointWebServerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
|
|
||||||
#include <map>
|
#include <algorithm>
|
||||||
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "WifiCredentialStore.h"
|
#include "WifiCredentialStore.h"
|
||||||
@ -124,48 +124,55 @@ void WifiSelectionActivity::processWifiScanResults() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Scan complete, process results
|
// Scan complete, process results
|
||||||
// Use a map to deduplicate networks by SSID, keeping the strongest signal
|
// Deduplicate directly into the networks vector (avoids std::map overhead)
|
||||||
std::map<std::string, WifiNetworkInfo> uniqueNetworks;
|
networks.clear();
|
||||||
|
networks.reserve(std::min(scanResult, static_cast<int16_t>(20))); // Limit to 20 networks max
|
||||||
|
|
||||||
for (int i = 0; i < scanResult; i++) {
|
for (int i = 0; i < scanResult; i++) {
|
||||||
std::string ssid = WiFi.SSID(i).c_str();
|
String ssidStr = WiFi.SSID(i);
|
||||||
const int32_t rssi = WiFi.RSSI(i);
|
const int32_t rssi = WiFi.RSSI(i);
|
||||||
|
|
||||||
// Skip hidden networks (empty SSID)
|
// Skip hidden networks (empty SSID)
|
||||||
if (ssid.empty()) {
|
if (ssidStr.isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we've already seen this SSID
|
std::string ssid = ssidStr.c_str();
|
||||||
auto it = uniqueNetworks.find(ssid);
|
|
||||||
if (it == uniqueNetworks.end() || rssi > it->second.rssi) {
|
// Check if we've already seen this SSID (linear search is fine for small lists)
|
||||||
// New network or stronger signal than existing entry
|
auto existing = std::find_if(networks.begin(), networks.end(),
|
||||||
|
[&ssid](const WifiNetworkInfo& net) { return net.ssid == ssid; });
|
||||||
|
|
||||||
|
if (existing != networks.end()) {
|
||||||
|
// Update if stronger signal
|
||||||
|
if (rssi > existing->rssi) {
|
||||||
|
existing->rssi = rssi;
|
||||||
|
existing->isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
|
||||||
|
}
|
||||||
|
} else if (networks.size() < 20) {
|
||||||
|
// New network - only add if under limit
|
||||||
WifiNetworkInfo network;
|
WifiNetworkInfo network;
|
||||||
network.ssid = ssid;
|
network.ssid = std::move(ssid);
|
||||||
network.rssi = rssi;
|
network.rssi = rssi;
|
||||||
network.isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
|
network.isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
|
||||||
network.hasSavedPassword = WIFI_STORE.hasSavedCredential(network.ssid);
|
network.hasSavedPassword = WIFI_STORE.hasSavedCredential(network.ssid);
|
||||||
uniqueNetworks[ssid] = network;
|
networks.push_back(std::move(network));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert map to vector
|
// Free WiFi scan memory immediately (before sorting)
|
||||||
networks.clear();
|
WiFi.scanDelete();
|
||||||
for (const auto& pair : uniqueNetworks) {
|
|
||||||
// cppcheck-suppress useStlAlgorithm
|
|
||||||
networks.push_back(pair.second);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by signal strength (strongest first)
|
// Sort by signal strength (strongest first), then by saved password
|
||||||
std::sort(networks.begin(), networks.end(),
|
|
||||||
[](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { return a.rssi > b.rssi; });
|
|
||||||
|
|
||||||
// Show networks with PW first
|
|
||||||
std::sort(networks.begin(), networks.end(), [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) {
|
std::sort(networks.begin(), networks.end(), [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) {
|
||||||
return a.hasSavedPassword && !b.hasSavedPassword;
|
// Primary: saved passwords first
|
||||||
|
if (a.hasSavedPassword != b.hasSavedPassword) {
|
||||||
|
return a.hasSavedPassword;
|
||||||
|
}
|
||||||
|
// Secondary: strongest signal first
|
||||||
|
return a.rssi > b.rssi;
|
||||||
});
|
});
|
||||||
|
|
||||||
WiFi.scanDelete();
|
|
||||||
state = WifiSelectionState::NETWORK_LIST;
|
state = WifiSelectionState::NETWORK_LIST;
|
||||||
selectedNetworkIndex = 0;
|
selectedNetworkIndex = 0;
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
|
|||||||
50
src/main.cpp
50
src/main.cpp
@ -130,11 +130,14 @@ void logMemoryState(const char* tag, const char* context) {
|
|||||||
#define logMemoryState(tag, context) ((void)0)
|
#define logMemoryState(tag, context) ((void)0)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Flash command detection - receives "FLASH\n" from pre_flash.py script
|
// Flash command detection - receives "FLASH:version\n" from pre_flash.py script
|
||||||
|
// Plan A: Simple polling - host sends command, device checks when Serial is connected
|
||||||
static String flashCmdBuffer;
|
static String flashCmdBuffer;
|
||||||
|
|
||||||
void checkForFlashCommand() {
|
void checkForFlashCommand() {
|
||||||
if (!Serial) return; // Early exit if Serial not initialized
|
// Only check when Serial is connected (host has port open)
|
||||||
|
if (!Serial) return;
|
||||||
|
|
||||||
while (Serial.available()) {
|
while (Serial.available()) {
|
||||||
char c = Serial.read();
|
char c = Serial.read();
|
||||||
if (c == '\n') {
|
if (c == '\n') {
|
||||||
@ -165,56 +168,51 @@ void checkForFlashCommand() {
|
|||||||
const int screenH = renderer.getScreenHeight();
|
const int screenH = renderer.getScreenHeight();
|
||||||
|
|
||||||
// Show current version in bottom-left corner (orientation-aware)
|
// Show current version in bottom-left corner (orientation-aware)
|
||||||
// "Bottom-left" is relative to the current orientation
|
|
||||||
constexpr int versionMargin = 10;
|
constexpr int versionMargin = 10;
|
||||||
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION);
|
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION);
|
||||||
int versionX, versionY;
|
int versionX, versionY;
|
||||||
switch (renderer.getOrientation()) {
|
switch (renderer.getOrientation()) {
|
||||||
case GfxRenderer::Portrait: // Bottom-left is actual bottom-left
|
case GfxRenderer::Portrait:
|
||||||
versionX = versionMargin;
|
versionX = versionMargin;
|
||||||
versionY = screenH - 30;
|
versionY = screenH - 30;
|
||||||
break;
|
break;
|
||||||
case GfxRenderer::PortraitInverted: // Bottom-left is actual top-right
|
case GfxRenderer::PortraitInverted:
|
||||||
versionX = screenW - textWidth - versionMargin;
|
versionX = screenW - textWidth - versionMargin;
|
||||||
versionY = 20;
|
versionY = 20;
|
||||||
break;
|
break;
|
||||||
case GfxRenderer::LandscapeClockwise: // Bottom-left is actual bottom-right
|
case GfxRenderer::LandscapeClockwise:
|
||||||
versionX = screenW - textWidth - versionMargin;
|
versionX = screenW - textWidth - versionMargin;
|
||||||
versionY = screenH - 30;
|
versionY = screenH - 30;
|
||||||
break;
|
break;
|
||||||
case GfxRenderer::LandscapeCounterClockwise: // Bottom-left is actual bottom-left
|
case GfxRenderer::LandscapeCounterClockwise:
|
||||||
versionX = versionMargin;
|
versionX = versionMargin;
|
||||||
versionY = screenH - 30;
|
versionY = screenH - 30;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
renderer.drawText(SMALL_FONT_ID, versionX, versionY, CROSSPOINT_VERSION, false);
|
renderer.drawText(SMALL_FONT_ID, versionX, versionY, CROSSPOINT_VERSION, false);
|
||||||
|
|
||||||
// Position and rotate lock icon based on current orientation (USB port location)
|
// Position and rotate lock icon based on current orientation
|
||||||
// USB port locations: Portrait=bottom-left, PortraitInverted=top-right,
|
constexpr int edgeMargin = 28;
|
||||||
// LandscapeCW=top-left, LandscapeCCW=bottom-right
|
constexpr int halfWidth = LOCK_ICON_WIDTH / 2;
|
||||||
// Position offsets: edge margin + half-width offset to center on USB port
|
|
||||||
constexpr int edgeMargin = 28; // Distance from screen edge
|
|
||||||
constexpr int halfWidth = LOCK_ICON_WIDTH / 2; // 16px offset for centering
|
|
||||||
int iconX, iconY;
|
int iconX, iconY;
|
||||||
GfxRenderer::ImageRotation rotation;
|
GfxRenderer::ImageRotation rotation;
|
||||||
// Note: 90/270 rotation swaps output dimensions (W<->H)
|
|
||||||
switch (renderer.getOrientation()) {
|
switch (renderer.getOrientation()) {
|
||||||
case GfxRenderer::Portrait: // USB at bottom-left, shackle points right
|
case GfxRenderer::Portrait:
|
||||||
rotation = GfxRenderer::ROTATE_90;
|
rotation = GfxRenderer::ROTATE_90;
|
||||||
iconX = edgeMargin;
|
iconX = edgeMargin;
|
||||||
iconY = screenH - LOCK_ICON_WIDTH - edgeMargin - halfWidth;
|
iconY = screenH - LOCK_ICON_WIDTH - edgeMargin - halfWidth;
|
||||||
break;
|
break;
|
||||||
case GfxRenderer::PortraitInverted: // USB at top-right, shackle points left
|
case GfxRenderer::PortraitInverted:
|
||||||
rotation = GfxRenderer::ROTATE_270;
|
rotation = GfxRenderer::ROTATE_270;
|
||||||
iconX = screenW - LOCK_ICON_HEIGHT - edgeMargin;
|
iconX = screenW - LOCK_ICON_HEIGHT - edgeMargin;
|
||||||
iconY = edgeMargin + halfWidth;
|
iconY = edgeMargin + halfWidth;
|
||||||
break;
|
break;
|
||||||
case GfxRenderer::LandscapeClockwise: // USB at top-left, shackle points down
|
case GfxRenderer::LandscapeClockwise:
|
||||||
rotation = GfxRenderer::ROTATE_180;
|
rotation = GfxRenderer::ROTATE_180;
|
||||||
iconX = edgeMargin + halfWidth;
|
iconX = edgeMargin + halfWidth;
|
||||||
iconY = edgeMargin;
|
iconY = edgeMargin;
|
||||||
break;
|
break;
|
||||||
case GfxRenderer::LandscapeCounterClockwise: // USB at bottom-right, shackle points up
|
case GfxRenderer::LandscapeCounterClockwise:
|
||||||
rotation = GfxRenderer::ROTATE_0;
|
rotation = GfxRenderer::ROTATE_0;
|
||||||
iconX = screenW - LOCK_ICON_WIDTH - edgeMargin - halfWidth;
|
iconX = screenW - LOCK_ICON_WIDTH - edgeMargin - halfWidth;
|
||||||
iconY = screenH - LOCK_ICON_HEIGHT - edgeMargin;
|
iconY = screenH - LOCK_ICON_HEIGHT - edgeMargin;
|
||||||
@ -222,13 +220,13 @@ void checkForFlashCommand() {
|
|||||||
}
|
}
|
||||||
renderer.drawImageRotated(LockIcon, iconX, iconY, LOCK_ICON_WIDTH, LOCK_ICON_HEIGHT, rotation);
|
renderer.drawImageRotated(LockIcon, iconX, iconY, LOCK_ICON_WIDTH, LOCK_ICON_HEIGHT, rotation);
|
||||||
|
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
// Use full refresh for clean display before flash overwrites firmware
|
||||||
|
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
||||||
}
|
}
|
||||||
flashCmdBuffer = "";
|
flashCmdBuffer = "";
|
||||||
} else if (c != '\r') {
|
} else if (c != '\r') {
|
||||||
flashCmdBuffer += c;
|
flashCmdBuffer += c;
|
||||||
// Prevent buffer overflow from random serial data (increased for version info)
|
if (flashCmdBuffer.length() > 50) {
|
||||||
if (flashCmdBuffer.length() > 30) {
|
|
||||||
flashCmdBuffer = "";
|
flashCmdBuffer = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -388,6 +386,7 @@ void onGoToFileTransfer() {
|
|||||||
APP_STATE.openBookTitle.shrink_to_fit();
|
APP_STATE.openBookTitle.shrink_to_fit();
|
||||||
APP_STATE.openBookAuthor.clear();
|
APP_STATE.openBookAuthor.clear();
|
||||||
APP_STATE.openBookAuthor.shrink_to_fit();
|
APP_STATE.openBookAuthor.shrink_to_fit();
|
||||||
|
HomeActivity::freeCoverBufferIfAllocated(); // Free 48KB cover buffer
|
||||||
Serial.printf("[%lu] [FT] Cleared non-essential memory before File Transfer\n", millis());
|
Serial.printf("[%lu] [FT] Cleared non-essential memory before File Transfer\n", millis());
|
||||||
|
|
||||||
enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
|
enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
|
||||||
@ -485,11 +484,14 @@ bool isWakeupByPowerButton() {
|
|||||||
void setup() {
|
void setup() {
|
||||||
t1 = millis();
|
t1 = millis();
|
||||||
|
|
||||||
// Only start serial if USB connected
|
// Always initialize Serial - safe on ESP32-C3 USB CDC even without USB connected
|
||||||
|
// (the peripheral just remains idle).
|
||||||
pinMode(UART0_RXD, INPUT);
|
pinMode(UART0_RXD, INPUT);
|
||||||
|
Serial.begin(115200);
|
||||||
|
|
||||||
|
// Only wait for terminal connection if USB is physically connected
|
||||||
|
// This allows catching early debug logs when a serial monitor is attached
|
||||||
if (isUsbConnected()) {
|
if (isUsbConnected()) {
|
||||||
Serial.begin(115200);
|
|
||||||
// Wait up to 3 seconds for Serial to be ready to catch early logs
|
|
||||||
unsigned long start = millis();
|
unsigned long start = millis();
|
||||||
while (!Serial && (millis() - start) < 3000) {
|
while (!Serial && (millis() - start) < 3000) {
|
||||||
delay(10);
|
delay(10);
|
||||||
|
|||||||
@ -371,27 +371,10 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function<void(F
|
|||||||
if (info.isDirectory) {
|
if (info.isDirectory) {
|
||||||
info.size = 0;
|
info.size = 0;
|
||||||
info.isEpub = false;
|
info.isEpub = false;
|
||||||
// md5 remains empty for directories
|
|
||||||
} else {
|
} else {
|
||||||
info.size = file.size();
|
info.size = file.size();
|
||||||
info.isEpub = isEpubFile(info.name);
|
info.isEpub = isEpubFile(info.name);
|
||||||
|
// MD5 not included in listing - clients can request via /api/hash endpoint
|
||||||
// For EPUBs, try to get cached MD5 hash
|
|
||||||
if (info.isEpub) {
|
|
||||||
// Build full file path
|
|
||||||
String fullPath = String(path);
|
|
||||||
if (!fullPath.endsWith("/")) {
|
|
||||||
fullPath += "/";
|
|
||||||
}
|
|
||||||
fullPath += fileName;
|
|
||||||
|
|
||||||
const std::string cachedMd5 =
|
|
||||||
Md5Utils::getCachedMd5(fullPath.c_str(), BookManager::CROSSPOINT_DIR, info.size);
|
|
||||||
if (!cachedMd5.empty()) {
|
|
||||||
info.md5 = String(cachedMd5.c_str());
|
|
||||||
}
|
|
||||||
// If not cached, md5 remains empty (companion app can request via /api/hash)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
callback(info);
|
callback(info);
|
||||||
@ -435,89 +418,72 @@ void CrossPointWebServer::handleFileListData() const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we should show hidden files
|
// Check if we should show hidden files (fork addition)
|
||||||
bool showHidden = false;
|
bool showHidden = server->hasArg("showHidden") && server->arg("showHidden") == "true";
|
||||||
if (server->hasArg("showHidden")) {
|
|
||||||
showHidden = server->arg("showHidden") == "true";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check client connection before starting
|
|
||||||
if (!server->client().connected()) {
|
|
||||||
Serial.printf("[%lu] [WEB] Client disconnected before file list could start\n", millis());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
|
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||||
server->send(200, "application/json", "");
|
server->send(200, "application/json", "");
|
||||||
if (!sendContentSafe("[")) {
|
|
||||||
Serial.printf("[%lu] [WEB] Client disconnected at start of file list\n", millis());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
char output[512];
|
// Batch JSON entries to reduce number of sendContent calls
|
||||||
|
// This helps prevent TCP buffer overflow on memory-constrained systems
|
||||||
|
constexpr size_t BATCH_SIZE = 2048;
|
||||||
|
char batch[BATCH_SIZE];
|
||||||
|
size_t batchPos = 0;
|
||||||
|
batch[batchPos++] = '[';
|
||||||
|
|
||||||
|
char output[256]; // Single entry buffer (reduced from 512)
|
||||||
constexpr size_t outputSize = sizeof(output);
|
constexpr size_t outputSize = sizeof(output);
|
||||||
bool seenFirst = false;
|
bool seenFirst = false;
|
||||||
bool clientDisconnected = false;
|
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
|
|
||||||
scanFiles(
|
scanFiles(
|
||||||
currentPath.c_str(),
|
currentPath.c_str(),
|
||||||
[this, &output, &doc, &seenFirst, &clientDisconnected](const FileInfo& info) mutable {
|
[this, &batch, &batchPos, &output, &doc, &seenFirst](const FileInfo& info) mutable {
|
||||||
// Skip remaining files if client already disconnected
|
|
||||||
if (clientDisconnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
doc.clear();
|
doc.clear();
|
||||||
doc["name"] = info.name;
|
doc["name"] = info.name;
|
||||||
doc["size"] = info.size;
|
doc["size"] = info.size;
|
||||||
doc["isDirectory"] = info.isDirectory;
|
doc["isDirectory"] = info.isDirectory;
|
||||||
doc["isEpub"] = info.isEpub;
|
doc["isEpub"] = info.isEpub;
|
||||||
|
|
||||||
// Include md5 field for EPUBs (null if not cached, hash string if available)
|
|
||||||
if (info.isEpub) {
|
|
||||||
if (info.md5.isEmpty()) {
|
|
||||||
doc["md5"] = nullptr; // JSON null
|
|
||||||
} else {
|
|
||||||
doc["md5"] = info.md5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const size_t written = serializeJson(doc, output, outputSize);
|
const size_t written = serializeJson(doc, output, outputSize);
|
||||||
if (written >= outputSize) {
|
if (written >= outputSize) {
|
||||||
// JSON output truncated; skip this entry to avoid sending malformed JSON
|
|
||||||
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(),
|
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(),
|
||||||
info.name.c_str());
|
info.name.c_str());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send comma separator before all entries except the first
|
// Calculate space needed: comma (if not first) + entry
|
||||||
|
const size_t needed = (seenFirst ? 1 : 0) + written;
|
||||||
|
|
||||||
|
// If batch would overflow, send it first
|
||||||
|
if (batchPos + needed >= BATCH_SIZE - 1) {
|
||||||
|
batch[batchPos] = '\0';
|
||||||
|
server->sendContent(batch);
|
||||||
|
delay(5); // Brief delay between batch sends
|
||||||
|
batchPos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add comma separator if not first entry
|
||||||
if (seenFirst) {
|
if (seenFirst) {
|
||||||
if (!sendContentSafe(",")) {
|
batch[batchPos++] = ',';
|
||||||
clientDisconnected = true;
|
|
||||||
Serial.printf("[%lu] [WEB] Client disconnected during file list\n", millis());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
seenFirst = true;
|
seenFirst = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the JSON entry with flow control
|
// Copy entry to batch
|
||||||
if (!sendContentSafe(output)) {
|
memcpy(batch + batchPos, output, written);
|
||||||
clientDisconnected = true;
|
batchPos += written;
|
||||||
Serial.printf("[%lu] [WEB] Client disconnected during file list\n", millis());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
showHidden);
|
showHidden);
|
||||||
|
|
||||||
// Only send closing bracket if client is still connected
|
// Send remaining batch with closing bracket
|
||||||
if (!clientDisconnected) {
|
batch[batchPos++] = ']';
|
||||||
sendContentSafe("]");
|
batch[batchPos] = '\0';
|
||||||
// End of streamed response, empty chunk to signal client
|
server->sendContent(batch);
|
||||||
server->sendContent("");
|
|
||||||
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
|
// End of streamed response, empty chunk to signal client
|
||||||
}
|
server->sendContent("");
|
||||||
|
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static variables for upload handling
|
// Static variables for upload handling
|
||||||
@ -1295,33 +1261,11 @@ void CrossPointWebServer::handleRename() const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Counter for flow control pacing
|
|
||||||
static uint8_t sendContentCounter = 0;
|
|
||||||
|
|
||||||
bool CrossPointWebServer::sendContentSafe(const char* content) const {
|
bool CrossPointWebServer::sendContentSafe(const char* content) const {
|
||||||
if (!server || !server->client().connected()) {
|
if (!server || !server->client().connected()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the content
|
|
||||||
server->sendContent(content);
|
server->sendContent(content);
|
||||||
|
|
||||||
// Flow control: give TCP stack time to transmit data and drain the send buffer
|
|
||||||
// The ESP32 TCP buffer is limited and fills quickly when streaming many small chunks.
|
|
||||||
// We use progressive delays:
|
|
||||||
// - yield() after every send to allow WiFi processing
|
|
||||||
// - delay(5ms) every send to allow buffer draining
|
|
||||||
// - delay(50ms) every 10 sends to allow larger buffer flush
|
|
||||||
yield();
|
|
||||||
sendContentCounter++;
|
|
||||||
|
|
||||||
if (sendContentCounter >= 10) {
|
|
||||||
sendContentCounter = 0;
|
|
||||||
delay(50); // Longer pause every 10 sends for buffer catchup
|
|
||||||
} else {
|
|
||||||
delay(5); // Short pause each send
|
|
||||||
}
|
|
||||||
|
|
||||||
return server->client().connected();
|
return server->client().connected();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,6 @@ struct FileInfo {
|
|||||||
size_t size;
|
size_t size;
|
||||||
bool isEpub;
|
bool isEpub;
|
||||||
bool isDirectory;
|
bool isDirectory;
|
||||||
String md5; // MD5 hash for EPUBs (empty if not cached/available)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class CrossPointWebServer {
|
class CrossPointWebServer {
|
||||||
@ -108,7 +107,7 @@ class CrossPointWebServer {
|
|||||||
bool copyFile(const String& srcPath, const String& destPath) const;
|
bool copyFile(const String& srcPath, const String& destPath) const;
|
||||||
bool copyFolder(const String& srcPath, const String& destPath) const;
|
bool copyFolder(const String& srcPath, const String& destPath) const;
|
||||||
|
|
||||||
// Helper for safe content sending with flow control
|
// Helper for safe content sending with connection check
|
||||||
// Returns false if client disconnected, true otherwise
|
// Returns false if client disconnected, true otherwise
|
||||||
bool sendContentSafe(const char* content) const;
|
bool sendContentSafe(const char* content) const;
|
||||||
bool sendContentSafe(const String& content) const;
|
bool sendContentSafe(const String& content) const;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user