Turbocharge WiFi uploads with WebSocket + watchdog stability (#364)
## Summary
* **What is the goal of this PR?** Fix WiFi file transfer stability
issues (especially crashes during uploads) and improve upload speed via
WebSocket binary protocol. File transfers now don't really crash as
much, if they do it recovers and speed has gone form 50KB/s to 300+KB/s.
* **What changes are included?**
- **WebSocket upload support** - Adds WebSocket binary protocol for file
uploads, achieving faster speeds 335 KB/s vs HTTP multipart)
- **Watchdog stability fixes** - Adds `esp_task_wdt_reset()` calls
throughout upload path to prevent watchdog timeouts during:
- File creation (FAT allocation can be slow)
- SD card write operations
- HTTP header parsing
- WebSocket chunk processing
- **4KB write buffering** - Batches SD card writes to reduce I/O
overhead
- **WiFi health monitoring** - Detects WiFi disconnection in STA mode
and exits gracefully
- **Improved handleClient loop** - 500 iterations with periodic watchdog
resets and button checks for responsiveness
- **Progress bar improvements** - Fixed jumping/inaccurate progress by
capping local progress at 95% until server confirms completion
- **Exit button responsiveness** - Button now checked inside the
handleClient loop every 64 iterations
- **Reduced exit delays** - Decreased shutdown delays from ~850ms to
~140ms
**Files changed:**
- `platformio.ini` - Added WebSockets library dependency
- `CrossPointWebServer.cpp/h` - WebSocket server, upload buffering,
watchdog resets
- `CrossPointWebServerActivity.cpp` - WiFi monitoring, improved loop,
button handling
- `FilesPage.html` - WebSocket upload JavaScript with HTTP fallback
## Additional Context
- WebSocket uses 4KB chunks with backpressure management to prevent
ESP32 buffer overflow
- Falls back to HTTP automatically if WebSocket connection fails
- The main bottleneck now is SD card write speed (~44% of transfer
time), not WiFi
- STA mode was more prone to crashes than AP mode due to external
network factors; WiFi health monitoring helps detect and handle
disconnections gracefully
---
### AI Usage
Did you use AI tools to help write this code? _**YES**_ Claude did it
ALL, I have no idea what I am doing, but my books transfer fast now.
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,7 @@ lib_deps =
|
||||
SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager
|
||||
ArduinoJson @ 7.4.2
|
||||
QRCode @ 0.0.1
|
||||
links2004/WebSockets @ ^2.4.1
|
||||
|
||||
[env:default]
|
||||
extends = base
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <ESPmDNS.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_task_wdt.h>
|
||||
#include <qrcode.h>
|
||||
|
||||
#include <cstddef>
|
||||
@@ -83,9 +84,8 @@ void CrossPointWebServerActivity::onExit() {
|
||||
dnsServer = nullptr;
|
||||
}
|
||||
|
||||
// CRITICAL: Wait for LWIP stack to flush any pending packets
|
||||
Serial.printf("[%lu] [WEBACT] Waiting 500ms for network stack to flush pending packets...\n", millis());
|
||||
delay(500);
|
||||
// Brief wait for LWIP stack to flush pending packets
|
||||
delay(50);
|
||||
|
||||
// Disconnect WiFi gracefully
|
||||
if (isApMode) {
|
||||
@@ -95,11 +95,11 @@ void CrossPointWebServerActivity::onExit() {
|
||||
Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis());
|
||||
WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame
|
||||
}
|
||||
delay(100); // Allow disconnect frame to be sent
|
||||
delay(30); // Allow disconnect frame to be sent
|
||||
|
||||
Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis());
|
||||
WiFi.mode(WIFI_OFF);
|
||||
delay(100); // Allow WiFi hardware to fully power down
|
||||
delay(30); // Allow WiFi hardware to power down
|
||||
|
||||
Serial.printf("[%lu] [WEBACT] [MEM] Free heap after WiFi disconnect: %d bytes\n", millis(), ESP.getFreeHeap());
|
||||
|
||||
@@ -283,8 +283,28 @@ void CrossPointWebServerActivity::loop() {
|
||||
dnsServer->processNextRequest();
|
||||
}
|
||||
|
||||
// Handle web server requests - call handleClient multiple times per loop
|
||||
// to improve responsiveness and upload throughput
|
||||
// STA mode: Monitor WiFi connection health
|
||||
if (!isApMode && webServer && webServer->isRunning()) {
|
||||
static unsigned long lastWifiCheck = 0;
|
||||
if (millis() - lastWifiCheck > 2000) { // Check every 2 seconds
|
||||
lastWifiCheck = millis();
|
||||
const wl_status_t wifiStatus = WiFi.status();
|
||||
if (wifiStatus != WL_CONNECTED) {
|
||||
Serial.printf("[%lu] [WEBACT] WiFi disconnected! Status: %d\n", millis(), wifiStatus);
|
||||
// Show error and exit gracefully
|
||||
state = WebServerActivityState::SHUTTING_DOWN;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
// Log weak signal warnings
|
||||
const int rssi = WiFi.RSSI();
|
||||
if (rssi < -75) {
|
||||
Serial.printf("[%lu] [WEBACT] Warning: Weak WiFi signal: %d dBm\n", millis(), rssi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle web server requests - maximize throughput with watchdog safety
|
||||
if (webServer && webServer->isRunning()) {
|
||||
const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime;
|
||||
|
||||
@@ -294,17 +314,32 @@ void CrossPointWebServerActivity::loop() {
|
||||
timeSinceLastHandleClient);
|
||||
}
|
||||
|
||||
// Call handleClient multiple times to process pending requests faster
|
||||
// This is critical for upload performance - HTTP file uploads send data
|
||||
// in chunks and each handleClient() call processes incoming data
|
||||
constexpr int HANDLE_CLIENT_ITERATIONS = 10;
|
||||
for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) {
|
||||
// Reset watchdog BEFORE processing - HTTP header parsing can be slow
|
||||
esp_task_wdt_reset();
|
||||
|
||||
// Process HTTP requests in tight loop for maximum throughput
|
||||
// More iterations = more data processed per main loop cycle
|
||||
constexpr int MAX_ITERATIONS = 500;
|
||||
for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) {
|
||||
webServer->handleClient();
|
||||
// Reset watchdog every 32 iterations
|
||||
if ((i & 0x1F) == 0x1F) {
|
||||
esp_task_wdt_reset();
|
||||
}
|
||||
// Yield and check for exit button every 64 iterations
|
||||
if ((i & 0x3F) == 0x3F) {
|
||||
yield();
|
||||
// Check for exit button inside loop for responsiveness
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onGoBack();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastHandleClientTime = millis();
|
||||
}
|
||||
|
||||
// Handle exit on Back button
|
||||
// Handle exit on Back button (also check outside loop)
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onGoBack();
|
||||
return;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <FsHelpers.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
@@ -15,6 +16,18 @@ namespace {
|
||||
// Note: Items starting with "." are automatically hidden
|
||||
const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"};
|
||||
constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]);
|
||||
|
||||
// Static pointer for WebSocket callback (WebSocketsServer requires C-style callback)
|
||||
CrossPointWebServer* wsInstance = nullptr;
|
||||
|
||||
// WebSocket upload state
|
||||
FsFile wsUploadFile;
|
||||
String wsUploadFileName;
|
||||
String wsUploadPath;
|
||||
size_t wsUploadSize = 0;
|
||||
size_t wsUploadReceived = 0;
|
||||
unsigned long wsUploadStartTime = 0;
|
||||
bool wsUploadInProgress = false;
|
||||
} // namespace
|
||||
|
||||
// File listing page template - now using generated headers:
|
||||
@@ -86,12 +99,22 @@ void CrossPointWebServer::begin() {
|
||||
Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap());
|
||||
|
||||
server->begin();
|
||||
|
||||
// Start WebSocket server for fast binary uploads
|
||||
Serial.printf("[%lu] [WEB] Starting WebSocket server on port %d...\n", millis(), wsPort);
|
||||
wsServer.reset(new WebSocketsServer(wsPort));
|
||||
wsInstance = const_cast<CrossPointWebServer*>(this);
|
||||
wsServer->begin();
|
||||
wsServer->onEvent(wsEventCallback);
|
||||
Serial.printf("[%lu] [WEB] WebSocket server started\n", millis());
|
||||
|
||||
running = true;
|
||||
|
||||
Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port);
|
||||
// Show the correct IP based on network mode
|
||||
const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString();
|
||||
Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), ipAddr.c_str());
|
||||
Serial.printf("[%lu] [WEB] WebSocket at ws://%s:%d/\n", millis(), ipAddr.c_str(), wsPort);
|
||||
Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap());
|
||||
}
|
||||
|
||||
@@ -107,16 +130,29 @@ void CrossPointWebServer::stop() {
|
||||
|
||||
Serial.printf("[%lu] [WEB] [MEM] Free heap before stop: %d bytes\n", millis(), ESP.getFreeHeap());
|
||||
|
||||
// Add delay to allow any in-flight handleClient() calls to complete
|
||||
delay(100);
|
||||
Serial.printf("[%lu] [WEB] Waited 100ms for handleClient to finish\n", millis());
|
||||
// Close any in-progress WebSocket upload
|
||||
if (wsUploadInProgress && wsUploadFile) {
|
||||
wsUploadFile.close();
|
||||
wsUploadInProgress = false;
|
||||
}
|
||||
|
||||
// Stop WebSocket server
|
||||
if (wsServer) {
|
||||
Serial.printf("[%lu] [WEB] Stopping WebSocket server...\n", millis());
|
||||
wsServer->close();
|
||||
wsServer.reset();
|
||||
wsInstance = nullptr;
|
||||
Serial.printf("[%lu] [WEB] WebSocket server stopped\n", millis());
|
||||
}
|
||||
|
||||
// Brief delay to allow any in-flight handleClient() calls to complete
|
||||
delay(20);
|
||||
|
||||
server->stop();
|
||||
Serial.printf("[%lu] [WEB] [MEM] Free heap after server->stop(): %d bytes\n", millis(), ESP.getFreeHeap());
|
||||
|
||||
// Add another delay before deletion to ensure server->stop() completes
|
||||
delay(50);
|
||||
Serial.printf("[%lu] [WEB] Waited 50ms before deleting server\n", millis());
|
||||
// Brief delay before deletion
|
||||
delay(10);
|
||||
|
||||
server.reset();
|
||||
Serial.printf("[%lu] [WEB] Web server stopped and deleted\n", millis());
|
||||
@@ -148,6 +184,11 @@ void CrossPointWebServer::handleClient() const {
|
||||
}
|
||||
|
||||
server->handleClient();
|
||||
|
||||
// Handle WebSocket events
|
||||
if (wsServer) {
|
||||
wsServer->loop();
|
||||
}
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleRoot() const {
|
||||
@@ -230,6 +271,7 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function<void(F
|
||||
|
||||
file.close();
|
||||
yield(); // Yield to allow WiFi and other tasks to process during long scans
|
||||
esp_task_wdt_reset(); // Reset watchdog to prevent timeout on large directories
|
||||
file = root.openNextFile();
|
||||
}
|
||||
root.close();
|
||||
@@ -301,11 +343,44 @@ static size_t uploadSize = 0;
|
||||
static bool uploadSuccess = false;
|
||||
static String uploadError = "";
|
||||
|
||||
// Upload write buffer - batches small writes into larger SD card operations
|
||||
// 4KB is a good balance: large enough to reduce syscall overhead, small enough
|
||||
// to keep individual write times short and avoid watchdog issues
|
||||
constexpr size_t UPLOAD_BUFFER_SIZE = 4096; // 4KB buffer
|
||||
static uint8_t uploadBuffer[UPLOAD_BUFFER_SIZE];
|
||||
static size_t uploadBufferPos = 0;
|
||||
|
||||
// Diagnostic counters for upload performance analysis
|
||||
static unsigned long uploadStartTime = 0;
|
||||
static unsigned long totalWriteTime = 0;
|
||||
static size_t writeCount = 0;
|
||||
|
||||
static bool flushUploadBuffer() {
|
||||
if (uploadBufferPos > 0 && uploadFile) {
|
||||
esp_task_wdt_reset(); // Reset watchdog before potentially slow SD write
|
||||
const unsigned long writeStart = millis();
|
||||
const size_t written = uploadFile.write(uploadBuffer, uploadBufferPos);
|
||||
totalWriteTime += millis() - writeStart;
|
||||
writeCount++;
|
||||
esp_task_wdt_reset(); // Reset watchdog after SD write
|
||||
|
||||
if (written != uploadBufferPos) {
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] Buffer flush failed: expected %d, wrote %d\n", millis(), uploadBufferPos,
|
||||
written);
|
||||
uploadBufferPos = 0;
|
||||
return false;
|
||||
}
|
||||
uploadBufferPos = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void CrossPointWebServer::handleUpload() const {
|
||||
static unsigned long lastWriteTime = 0;
|
||||
static unsigned long uploadStartTime = 0;
|
||||
static size_t lastLoggedSize = 0;
|
||||
|
||||
// Reset watchdog at start of every upload callback - HTTP parsing can be slow
|
||||
esp_task_wdt_reset();
|
||||
|
||||
// Safety check: ensure server is still valid
|
||||
if (!running || !server) {
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] ERROR: handleUpload called but server not running!\n", millis());
|
||||
@@ -315,13 +390,18 @@ void CrossPointWebServer::handleUpload() const {
|
||||
const HTTPUpload& upload = server->upload();
|
||||
|
||||
if (upload.status == UPLOAD_FILE_START) {
|
||||
// Reset watchdog - this is the critical 1% crash point
|
||||
esp_task_wdt_reset();
|
||||
|
||||
uploadFileName = upload.filename;
|
||||
uploadSize = 0;
|
||||
uploadSuccess = false;
|
||||
uploadError = "";
|
||||
uploadStartTime = millis();
|
||||
lastWriteTime = millis();
|
||||
lastLoggedSize = 0;
|
||||
uploadBufferPos = 0;
|
||||
totalWriteTime = 0;
|
||||
writeCount = 0;
|
||||
|
||||
// Get upload path from query parameter (defaults to root if not specified)
|
||||
// Note: We use query parameter instead of form data because multipart form
|
||||
@@ -348,60 +428,82 @@ void CrossPointWebServer::handleUpload() const {
|
||||
if (!filePath.endsWith("/")) filePath += "/";
|
||||
filePath += uploadFileName;
|
||||
|
||||
// Check if file already exists
|
||||
// Check if file already exists - SD operations can be slow
|
||||
esp_task_wdt_reset();
|
||||
if (SdMan.exists(filePath.c_str())) {
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] Overwriting existing file: %s\n", millis(), filePath.c_str());
|
||||
esp_task_wdt_reset();
|
||||
SdMan.remove(filePath.c_str());
|
||||
}
|
||||
|
||||
// Open file for writing
|
||||
// Open file for writing - this can be slow due to FAT cluster allocation
|
||||
esp_task_wdt_reset();
|
||||
if (!SdMan.openFileForWrite("WEB", filePath, uploadFile)) {
|
||||
uploadError = "Failed to create file on SD card";
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str());
|
||||
return;
|
||||
}
|
||||
esp_task_wdt_reset();
|
||||
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] File created successfully: %s\n", millis(), filePath.c_str());
|
||||
} else if (upload.status == UPLOAD_FILE_WRITE) {
|
||||
if (uploadFile && uploadError.isEmpty()) {
|
||||
const unsigned long writeStartTime = millis();
|
||||
const size_t written = uploadFile.write(upload.buf, upload.currentSize);
|
||||
const unsigned long writeEndTime = millis();
|
||||
const unsigned long writeDuration = writeEndTime - writeStartTime;
|
||||
// Buffer incoming data and flush when buffer is full
|
||||
// This reduces SD card write operations and improves throughput
|
||||
const uint8_t* data = upload.buf;
|
||||
size_t remaining = upload.currentSize;
|
||||
|
||||
if (written != upload.currentSize) {
|
||||
while (remaining > 0) {
|
||||
const size_t space = UPLOAD_BUFFER_SIZE - uploadBufferPos;
|
||||
const size_t toCopy = (remaining < space) ? remaining : space;
|
||||
|
||||
memcpy(uploadBuffer + uploadBufferPos, data, toCopy);
|
||||
uploadBufferPos += toCopy;
|
||||
data += toCopy;
|
||||
remaining -= toCopy;
|
||||
|
||||
// Flush buffer when full
|
||||
if (uploadBufferPos >= UPLOAD_BUFFER_SIZE) {
|
||||
if (!flushUploadBuffer()) {
|
||||
uploadError = "Failed to write to SD card - disk may be full";
|
||||
uploadFile.close();
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] WRITE ERROR - expected %d, wrote %d\n", millis(), upload.currentSize,
|
||||
written);
|
||||
} else {
|
||||
uploadSize += written;
|
||||
|
||||
// Log progress every 50KB or if write took >100ms
|
||||
if (uploadSize - lastLoggedSize >= 51200 || writeDuration > 100) {
|
||||
const unsigned long timeSinceStart = millis() - uploadStartTime;
|
||||
const unsigned long timeSinceLastWrite = millis() - lastWriteTime;
|
||||
const float kbps = (uploadSize / 1024.0) / (timeSinceStart / 1000.0);
|
||||
|
||||
Serial.printf(
|
||||
"[%lu] [WEB] [UPLOAD] Progress: %d bytes (%.1f KB), %.1f KB/s, write took %lu ms, gap since last: %lu "
|
||||
"ms\n",
|
||||
millis(), uploadSize, uploadSize / 1024.0, kbps, writeDuration, timeSinceLastWrite);
|
||||
lastLoggedSize = uploadSize;
|
||||
return;
|
||||
}
|
||||
lastWriteTime = millis();
|
||||
}
|
||||
}
|
||||
|
||||
uploadSize += upload.currentSize;
|
||||
|
||||
// Log progress every 100KB
|
||||
if (uploadSize - lastLoggedSize >= 102400) {
|
||||
const unsigned long elapsed = millis() - uploadStartTime;
|
||||
const float kbps = (elapsed > 0) ? (uploadSize / 1024.0) / (elapsed / 1000.0) : 0;
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] %d bytes (%.1f KB), %.1f KB/s, %d writes\n", millis(), uploadSize,
|
||||
uploadSize / 1024.0, kbps, writeCount);
|
||||
lastLoggedSize = uploadSize;
|
||||
}
|
||||
}
|
||||
} else if (upload.status == UPLOAD_FILE_END) {
|
||||
if (uploadFile) {
|
||||
// Flush any remaining buffered data
|
||||
if (!flushUploadBuffer()) {
|
||||
uploadError = "Failed to write final data to SD card";
|
||||
}
|
||||
uploadFile.close();
|
||||
|
||||
if (uploadError.isEmpty()) {
|
||||
uploadSuccess = true;
|
||||
Serial.printf("[%lu] [WEB] Upload complete: %s (%d bytes)\n", millis(), uploadFileName.c_str(), uploadSize);
|
||||
const unsigned long elapsed = millis() - uploadStartTime;
|
||||
const float avgKbps = (elapsed > 0) ? (uploadSize / 1024.0) / (elapsed / 1000.0) : 0;
|
||||
const float writePercent = (elapsed > 0) ? (totalWriteTime * 100.0 / elapsed) : 0;
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] Complete: %s (%d bytes in %lu ms, avg %.1f KB/s)\n", millis(),
|
||||
uploadFileName.c_str(), uploadSize, elapsed, avgKbps);
|
||||
Serial.printf("[%lu] [WEB] [UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)\n", millis(),
|
||||
writeCount, totalWriteTime, writePercent);
|
||||
}
|
||||
}
|
||||
} else if (upload.status == UPLOAD_FILE_ABORTED) {
|
||||
uploadBufferPos = 0; // Discard buffered data
|
||||
if (uploadFile) {
|
||||
uploadFile.close();
|
||||
// Try to delete the incomplete file
|
||||
@@ -555,3 +657,143 @@ void CrossPointWebServer::handleDelete() const {
|
||||
server->send(500, "text/plain", "Failed to delete item");
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket callback trampoline
|
||||
void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
|
||||
if (wsInstance) {
|
||||
wsInstance->onWebSocketEvent(num, type, payload, length);
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket event handler for fast binary uploads
|
||||
// Protocol:
|
||||
// 1. Client sends TEXT message: "START:<filename>:<size>:<path>"
|
||||
// 2. Client sends BINARY messages with file data chunks
|
||||
// 3. Server sends TEXT "PROGRESS:<received>:<total>" after each chunk
|
||||
// 4. Server sends TEXT "DONE" or "ERROR:<message>" when complete
|
||||
void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) {
|
||||
switch (type) {
|
||||
case WStype_DISCONNECTED:
|
||||
Serial.printf("[%lu] [WS] Client %u disconnected\n", millis(), num);
|
||||
// Clean up any in-progress upload
|
||||
if (wsUploadInProgress && wsUploadFile) {
|
||||
wsUploadFile.close();
|
||||
// Delete incomplete file
|
||||
String filePath = wsUploadPath;
|
||||
if (!filePath.endsWith("/")) filePath += "/";
|
||||
filePath += wsUploadFileName;
|
||||
SdMan.remove(filePath.c_str());
|
||||
Serial.printf("[%lu] [WS] Deleted incomplete upload: %s\n", millis(), filePath.c_str());
|
||||
}
|
||||
wsUploadInProgress = false;
|
||||
break;
|
||||
|
||||
case WStype_CONNECTED: {
|
||||
Serial.printf("[%lu] [WS] Client %u connected\n", millis(), num);
|
||||
break;
|
||||
}
|
||||
|
||||
case WStype_TEXT: {
|
||||
// Parse control messages
|
||||
String msg = String((char*)payload);
|
||||
Serial.printf("[%lu] [WS] Text from client %u: %s\n", millis(), num, msg.c_str());
|
||||
|
||||
if (msg.startsWith("START:")) {
|
||||
// Parse: START:<filename>:<size>:<path>
|
||||
int firstColon = msg.indexOf(':', 6);
|
||||
int secondColon = msg.indexOf(':', firstColon + 1);
|
||||
|
||||
if (firstColon > 0 && secondColon > 0) {
|
||||
wsUploadFileName = msg.substring(6, firstColon);
|
||||
wsUploadSize = msg.substring(firstColon + 1, secondColon).toInt();
|
||||
wsUploadPath = msg.substring(secondColon + 1);
|
||||
wsUploadReceived = 0;
|
||||
wsUploadStartTime = millis();
|
||||
|
||||
// Ensure path is valid
|
||||
if (!wsUploadPath.startsWith("/")) wsUploadPath = "/" + wsUploadPath;
|
||||
if (wsUploadPath.length() > 1 && wsUploadPath.endsWith("/")) {
|
||||
wsUploadPath = wsUploadPath.substring(0, wsUploadPath.length() - 1);
|
||||
}
|
||||
|
||||
// Build file path
|
||||
String filePath = wsUploadPath;
|
||||
if (!filePath.endsWith("/")) filePath += "/";
|
||||
filePath += wsUploadFileName;
|
||||
|
||||
Serial.printf("[%lu] [WS] Starting upload: %s (%d bytes) to %s\n", millis(), wsUploadFileName.c_str(),
|
||||
wsUploadSize, filePath.c_str());
|
||||
|
||||
// Check if file exists and remove it
|
||||
esp_task_wdt_reset();
|
||||
if (SdMan.exists(filePath.c_str())) {
|
||||
SdMan.remove(filePath.c_str());
|
||||
}
|
||||
|
||||
// Open file for writing
|
||||
esp_task_wdt_reset();
|
||||
if (!SdMan.openFileForWrite("WS", filePath, wsUploadFile)) {
|
||||
wsServer->sendTXT(num, "ERROR:Failed to create file");
|
||||
wsUploadInProgress = false;
|
||||
return;
|
||||
}
|
||||
esp_task_wdt_reset();
|
||||
|
||||
wsUploadInProgress = true;
|
||||
wsServer->sendTXT(num, "READY");
|
||||
} else {
|
||||
wsServer->sendTXT(num, "ERROR:Invalid START format");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case WStype_BIN: {
|
||||
if (!wsUploadInProgress || !wsUploadFile) {
|
||||
wsServer->sendTXT(num, "ERROR:No upload in progress");
|
||||
return;
|
||||
}
|
||||
|
||||
// Write binary data directly to file
|
||||
esp_task_wdt_reset();
|
||||
size_t written = wsUploadFile.write(payload, length);
|
||||
esp_task_wdt_reset();
|
||||
|
||||
if (written != length) {
|
||||
wsUploadFile.close();
|
||||
wsUploadInProgress = false;
|
||||
wsServer->sendTXT(num, "ERROR:Write failed - disk full?");
|
||||
return;
|
||||
}
|
||||
|
||||
wsUploadReceived += written;
|
||||
|
||||
// Send progress update (every 64KB or at end)
|
||||
static size_t lastProgressSent = 0;
|
||||
if (wsUploadReceived - lastProgressSent >= 65536 || wsUploadReceived >= wsUploadSize) {
|
||||
String progress = "PROGRESS:" + String(wsUploadReceived) + ":" + String(wsUploadSize);
|
||||
wsServer->sendTXT(num, progress);
|
||||
lastProgressSent = wsUploadReceived;
|
||||
}
|
||||
|
||||
// Check if upload complete
|
||||
if (wsUploadReceived >= wsUploadSize) {
|
||||
wsUploadFile.close();
|
||||
wsUploadInProgress = false;
|
||||
|
||||
unsigned long elapsed = millis() - wsUploadStartTime;
|
||||
float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0;
|
||||
|
||||
Serial.printf("[%lu] [WS] Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(),
|
||||
wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps);
|
||||
|
||||
wsServer->sendTXT(num, "DONE");
|
||||
lastProgressSent = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <WebServer.h>
|
||||
#include <WebSocketsServer.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
@@ -34,9 +35,15 @@ class CrossPointWebServer {
|
||||
|
||||
private:
|
||||
std::unique_ptr<WebServer> server = nullptr;
|
||||
std::unique_ptr<WebSocketsServer> wsServer = nullptr;
|
||||
bool running = false;
|
||||
bool apMode = false; // true when running in AP mode, false for STA mode
|
||||
uint16_t port = 80;
|
||||
uint16_t wsPort = 81; // WebSocket port
|
||||
|
||||
// WebSocket upload state
|
||||
void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length);
|
||||
static void wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length);
|
||||
|
||||
// File scanning
|
||||
void scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const;
|
||||
|
||||
@@ -816,6 +816,151 @@
|
||||
}
|
||||
|
||||
let failedUploadsGlobal = [];
|
||||
let wsConnection = null;
|
||||
const WS_PORT = 81;
|
||||
const WS_CHUNK_SIZE = 4096; // 4KB chunks - smaller for ESP32 stability
|
||||
|
||||
// Get WebSocket URL based on current page location
|
||||
function getWsUrl() {
|
||||
const host = window.location.hostname;
|
||||
return `ws://${host}:${WS_PORT}/`;
|
||||
}
|
||||
|
||||
// Upload file via WebSocket (faster, binary protocol)
|
||||
function uploadFileWebSocket(file, onProgress, onComplete, onError) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(getWsUrl());
|
||||
let uploadStarted = false;
|
||||
let sendingChunks = false;
|
||||
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = function() {
|
||||
console.log('[WS] Connected, starting upload:', file.name);
|
||||
// Send start message: START:<filename>:<size>:<path>
|
||||
ws.send(`START:${file.name}:${file.size}:${currentPath}`);
|
||||
};
|
||||
|
||||
ws.onmessage = async function(event) {
|
||||
const msg = event.data;
|
||||
console.log('[WS] Message:', msg);
|
||||
|
||||
if (msg === 'READY') {
|
||||
uploadStarted = true;
|
||||
sendingChunks = true;
|
||||
|
||||
// Small delay to let connection stabilize
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
try {
|
||||
// Send file in chunks
|
||||
const totalSize = file.size;
|
||||
let offset = 0;
|
||||
|
||||
while (offset < totalSize && ws.readyState === WebSocket.OPEN) {
|
||||
const chunkSize = Math.min(WS_CHUNK_SIZE, totalSize - offset);
|
||||
const chunk = file.slice(offset, offset + chunkSize);
|
||||
const buffer = await chunk.arrayBuffer();
|
||||
|
||||
// Wait for buffer to clear - more aggressive backpressure
|
||||
while (ws.bufferedAmount > WS_CHUNK_SIZE * 2 && ws.readyState === WebSocket.OPEN) {
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
}
|
||||
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('WebSocket closed during upload');
|
||||
}
|
||||
|
||||
ws.send(buffer);
|
||||
offset += chunkSize;
|
||||
|
||||
// Update local progress - cap at 95% since server still needs to write
|
||||
// Final 100% shown when server confirms DONE
|
||||
if (onProgress) {
|
||||
const cappedOffset = Math.min(offset, Math.floor(totalSize * 0.95));
|
||||
onProgress(cappedOffset, totalSize);
|
||||
}
|
||||
}
|
||||
|
||||
sendingChunks = false;
|
||||
console.log('[WS] All chunks sent, waiting for DONE');
|
||||
} catch (err) {
|
||||
console.error('[WS] Error sending chunks:', err);
|
||||
sendingChunks = false;
|
||||
ws.close();
|
||||
reject(err);
|
||||
}
|
||||
} else if (msg.startsWith('PROGRESS:')) {
|
||||
// Server confirmed progress - log for debugging but don't update UI
|
||||
// (local progress is smoother, server progress causes jumping)
|
||||
console.log('[WS] Server progress:', msg);
|
||||
} else if (msg === 'DONE') {
|
||||
// Show 100% when server confirms completion
|
||||
if (onProgress) onProgress(file.size, file.size);
|
||||
ws.close();
|
||||
if (onComplete) onComplete();
|
||||
resolve();
|
||||
} else if (msg.startsWith('ERROR:')) {
|
||||
const error = msg.substring(6);
|
||||
ws.close();
|
||||
if (onError) onError(error);
|
||||
reject(new Error(error));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = function(event) {
|
||||
console.error('[WS] Error:', event);
|
||||
if (!uploadStarted) {
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
} else if (!sendingChunks) {
|
||||
reject(new Error('WebSocket error during upload'));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function(event) {
|
||||
console.log('[WS] Connection closed, code:', event.code, 'reason:', event.reason);
|
||||
if (sendingChunks) {
|
||||
reject(new Error('WebSocket closed unexpectedly'));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Upload file via HTTP (fallback method)
|
||||
function uploadFileHTTP(file, onProgress, onComplete, onError) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
|
||||
|
||||
xhr.upload.onprogress = function(e) {
|
||||
if (e.lengthComputable && onProgress) {
|
||||
onProgress(e.loaded, e.total);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
if (onComplete) onComplete();
|
||||
resolve();
|
||||
} else {
|
||||
const error = xhr.responseText || 'Upload failed';
|
||||
if (onError) onError(error);
|
||||
reject(new Error(error));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function() {
|
||||
const error = 'Network error';
|
||||
if (onError) onError(error);
|
||||
reject(new Error(error));
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function uploadFile() {
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
@@ -836,8 +981,9 @@ function uploadFile() {
|
||||
|
||||
let currentIndex = 0;
|
||||
const failedFiles = [];
|
||||
let useWebSocket = true; // Try WebSocket first
|
||||
|
||||
function uploadNextFile() {
|
||||
async function uploadNextFile() {
|
||||
if (currentIndex >= files.length) {
|
||||
// All files processed - show summary
|
||||
if (failedFiles.length === 0) {
|
||||
@@ -845,67 +991,71 @@ function uploadFile() {
|
||||
progressText.textContent = 'All uploads complete!';
|
||||
setTimeout(() => {
|
||||
closeUploadModal();
|
||||
hydrate(); // Refresh file list instead of reloading
|
||||
hydrate();
|
||||
}, 1000);
|
||||
} else {
|
||||
progressFill.style.backgroundColor = '#e74c3c';
|
||||
const failedList = failedFiles.map(f => f.name).join(', ');
|
||||
progressText.textContent = `${files.length - failedFiles.length}/${files.length} uploaded. Failed: ${failedList}`;
|
||||
|
||||
// Store failed files globally and show banner
|
||||
failedUploadsGlobal = failedFiles;
|
||||
|
||||
setTimeout(() => {
|
||||
closeUploadModal();
|
||||
showFailedUploadsBanner();
|
||||
hydrate(); // Refresh file list to show successfully uploaded files
|
||||
hydrate();
|
||||
}, 2000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[currentIndex];
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
// Include path as query parameter since multipart form data doesn't make
|
||||
// form fields available until after file upload completes
|
||||
xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true);
|
||||
|
||||
progressFill.style.width = '0%';
|
||||
progressFill.style.backgroundColor = '#4caf50';
|
||||
progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})`;
|
||||
progressFill.style.backgroundColor = '#27ae60';
|
||||
const methodText = useWebSocket ? ' [WS]' : ' [HTTP]';
|
||||
progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText}`;
|
||||
|
||||
xhr.upload.onprogress = function (e) {
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.round((e.loaded / e.total) * 100);
|
||||
const onProgress = (loaded, total) => {
|
||||
const percent = Math.round((loaded / total) * 100);
|
||||
progressFill.style.width = percent + '%';
|
||||
progressText.textContent =
|
||||
`Uploading ${file.name} (${currentIndex + 1}/${files.length}) — ${percent}%`;
|
||||
}
|
||||
const speed = ''; // Could calculate speed here
|
||||
progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText} — ${percent}%`;
|
||||
};
|
||||
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
const onComplete = () => {
|
||||
currentIndex++;
|
||||
uploadNextFile(); // upload next file
|
||||
uploadNextFile();
|
||||
};
|
||||
|
||||
const onError = (error) => {
|
||||
failedFiles.push({ name: file.name, error: error, file: file });
|
||||
currentIndex++;
|
||||
uploadNextFile();
|
||||
};
|
||||
|
||||
try {
|
||||
if (useWebSocket) {
|
||||
await uploadFileWebSocket(file, onProgress, null, null);
|
||||
onComplete();
|
||||
} else {
|
||||
// Track failure and continue with next file
|
||||
failedFiles.push({ name: file.name, error: xhr.responseText, file: file });
|
||||
currentIndex++;
|
||||
uploadNextFile();
|
||||
await uploadFileHTTP(file, onProgress, null, null);
|
||||
onComplete();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
if (useWebSocket && error.message === 'WebSocket connection failed') {
|
||||
// Fall back to HTTP for all subsequent uploads
|
||||
console.log('WebSocket failed, falling back to HTTP');
|
||||
useWebSocket = false;
|
||||
// Retry this file with HTTP
|
||||
try {
|
||||
await uploadFileHTTP(file, onProgress, null, null);
|
||||
onComplete();
|
||||
} catch (httpError) {
|
||||
onError(httpError.message);
|
||||
}
|
||||
} else {
|
||||
onError(error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
// Track network error and continue with next file
|
||||
failedFiles.push({ name: file.name, error: 'network error', file: file });
|
||||
currentIndex++;
|
||||
uploadNextFile();
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
uploadNextFile();
|
||||
|
||||
Reference in New Issue
Block a user