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:
cottongin 2026-01-30 22:00:15 -05:00
parent 48267ad848
commit 5464d9de3a
No known key found for this signature in database
GPG Key ID: 0ECC91FE4655C262
9 changed files with 260 additions and 178 deletions

View File

@ -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**

View File

@ -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;

View File

@ -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)

View File

@ -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);
} }

View File

@ -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,

View File

@ -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;

View File

@ -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);

View File

@ -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();
} }

View File

@ -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;