A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

swwilshub
Claude
and committed by
GitHub
a946c83a 847786e3

+520 -85
+1
platformio.ini
··· 47 47 SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager 48 48 ArduinoJson @ 7.4.2 49 49 QRCode @ 0.0.1 50 + links2004/WebSockets @ ^2.4.1 50 51 51 52 [env:default] 52 53 extends = base
+48 -13
src/activities/network/CrossPointWebServerActivity.cpp
··· 4 4 #include <ESPmDNS.h> 5 5 #include <GfxRenderer.h> 6 6 #include <WiFi.h> 7 + #include <esp_task_wdt.h> 7 8 #include <qrcode.h> 8 9 9 10 #include <cstddef> ··· 83 84 dnsServer = nullptr; 84 85 } 85 86 86 - // CRITICAL: Wait for LWIP stack to flush any pending packets 87 - Serial.printf("[%lu] [WEBACT] Waiting 500ms for network stack to flush pending packets...\n", millis()); 88 - delay(500); 87 + // Brief wait for LWIP stack to flush pending packets 88 + delay(50); 89 89 90 90 // Disconnect WiFi gracefully 91 91 if (isApMode) { ··· 95 95 Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis()); 96 96 WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame 97 97 } 98 - delay(100); // Allow disconnect frame to be sent 98 + delay(30); // Allow disconnect frame to be sent 99 99 100 100 Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis()); 101 101 WiFi.mode(WIFI_OFF); 102 - delay(100); // Allow WiFi hardware to fully power down 102 + delay(30); // Allow WiFi hardware to power down 103 103 104 104 Serial.printf("[%lu] [WEBACT] [MEM] Free heap after WiFi disconnect: %d bytes\n", millis(), ESP.getFreeHeap()); 105 105 ··· 283 283 dnsServer->processNextRequest(); 284 284 } 285 285 286 - // Handle web server requests - call handleClient multiple times per loop 287 - // to improve responsiveness and upload throughput 286 + // STA mode: Monitor WiFi connection health 287 + if (!isApMode && webServer && webServer->isRunning()) { 288 + static unsigned long lastWifiCheck = 0; 289 + if (millis() - lastWifiCheck > 2000) { // Check every 2 seconds 290 + lastWifiCheck = millis(); 291 + const wl_status_t wifiStatus = WiFi.status(); 292 + if (wifiStatus != WL_CONNECTED) { 293 + Serial.printf("[%lu] [WEBACT] WiFi disconnected! Status: %d\n", millis(), wifiStatus); 294 + // Show error and exit gracefully 295 + state = WebServerActivityState::SHUTTING_DOWN; 296 + updateRequired = true; 297 + return; 298 + } 299 + // Log weak signal warnings 300 + const int rssi = WiFi.RSSI(); 301 + if (rssi < -75) { 302 + Serial.printf("[%lu] [WEBACT] Warning: Weak WiFi signal: %d dBm\n", millis(), rssi); 303 + } 304 + } 305 + } 306 + 307 + // Handle web server requests - maximize throughput with watchdog safety 288 308 if (webServer && webServer->isRunning()) { 289 309 const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; 290 310 ··· 294 314 timeSinceLastHandleClient); 295 315 } 296 316 297 - // Call handleClient multiple times to process pending requests faster 298 - // This is critical for upload performance - HTTP file uploads send data 299 - // in chunks and each handleClient() call processes incoming data 300 - constexpr int HANDLE_CLIENT_ITERATIONS = 10; 301 - for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) { 317 + // Reset watchdog BEFORE processing - HTTP header parsing can be slow 318 + esp_task_wdt_reset(); 319 + 320 + // Process HTTP requests in tight loop for maximum throughput 321 + // More iterations = more data processed per main loop cycle 322 + constexpr int MAX_ITERATIONS = 500; 323 + for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) { 302 324 webServer->handleClient(); 325 + // Reset watchdog every 32 iterations 326 + if ((i & 0x1F) == 0x1F) { 327 + esp_task_wdt_reset(); 328 + } 329 + // Yield and check for exit button every 64 iterations 330 + if ((i & 0x3F) == 0x3F) { 331 + yield(); 332 + // Check for exit button inside loop for responsiveness 333 + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { 334 + onGoBack(); 335 + return; 336 + } 337 + } 303 338 } 304 339 lastHandleClientTime = millis(); 305 340 } 306 341 307 - // Handle exit on Back button 342 + // Handle exit on Back button (also check outside loop) 308 343 if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { 309 344 onGoBack(); 310 345 return;
+277 -35
src/network/CrossPointWebServer.cpp
··· 4 4 #include <FsHelpers.h> 5 5 #include <SDCardManager.h> 6 6 #include <WiFi.h> 7 + #include <esp_task_wdt.h> 7 8 8 9 #include <algorithm> 9 10 ··· 15 16 // Note: Items starting with "." are automatically hidden 16 17 const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; 17 18 constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]); 19 + 20 + // Static pointer for WebSocket callback (WebSocketsServer requires C-style callback) 21 + CrossPointWebServer* wsInstance = nullptr; 22 + 23 + // WebSocket upload state 24 + FsFile wsUploadFile; 25 + String wsUploadFileName; 26 + String wsUploadPath; 27 + size_t wsUploadSize = 0; 28 + size_t wsUploadReceived = 0; 29 + unsigned long wsUploadStartTime = 0; 30 + bool wsUploadInProgress = false; 18 31 } // namespace 19 32 20 33 // File listing page template - now using generated headers: ··· 86 99 Serial.printf("[%lu] [WEB] [MEM] Free heap after route setup: %d bytes\n", millis(), ESP.getFreeHeap()); 87 100 88 101 server->begin(); 102 + 103 + // Start WebSocket server for fast binary uploads 104 + Serial.printf("[%lu] [WEB] Starting WebSocket server on port %d...\n", millis(), wsPort); 105 + wsServer.reset(new WebSocketsServer(wsPort)); 106 + wsInstance = const_cast<CrossPointWebServer*>(this); 107 + wsServer->begin(); 108 + wsServer->onEvent(wsEventCallback); 109 + Serial.printf("[%lu] [WEB] WebSocket server started\n", millis()); 110 + 89 111 running = true; 90 112 91 113 Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port); 92 114 // Show the correct IP based on network mode 93 115 const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); 94 116 Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), ipAddr.c_str()); 117 + Serial.printf("[%lu] [WEB] WebSocket at ws://%s:%d/\n", millis(), ipAddr.c_str(), wsPort); 95 118 Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap()); 96 119 } 97 120 ··· 107 130 108 131 Serial.printf("[%lu] [WEB] [MEM] Free heap before stop: %d bytes\n", millis(), ESP.getFreeHeap()); 109 132 110 - // Add delay to allow any in-flight handleClient() calls to complete 111 - delay(100); 112 - Serial.printf("[%lu] [WEB] Waited 100ms for handleClient to finish\n", millis()); 133 + // Close any in-progress WebSocket upload 134 + if (wsUploadInProgress && wsUploadFile) { 135 + wsUploadFile.close(); 136 + wsUploadInProgress = false; 137 + } 138 + 139 + // Stop WebSocket server 140 + if (wsServer) { 141 + Serial.printf("[%lu] [WEB] Stopping WebSocket server...\n", millis()); 142 + wsServer->close(); 143 + wsServer.reset(); 144 + wsInstance = nullptr; 145 + Serial.printf("[%lu] [WEB] WebSocket server stopped\n", millis()); 146 + } 147 + 148 + // Brief delay to allow any in-flight handleClient() calls to complete 149 + delay(20); 113 150 114 151 server->stop(); 115 152 Serial.printf("[%lu] [WEB] [MEM] Free heap after server->stop(): %d bytes\n", millis(), ESP.getFreeHeap()); 116 153 117 - // Add another delay before deletion to ensure server->stop() completes 118 - delay(50); 119 - Serial.printf("[%lu] [WEB] Waited 50ms before deleting server\n", millis()); 154 + // Brief delay before deletion 155 + delay(10); 120 156 121 157 server.reset(); 122 158 Serial.printf("[%lu] [WEB] Web server stopped and deleted\n", millis()); ··· 148 184 } 149 185 150 186 server->handleClient(); 187 + 188 + // Handle WebSocket events 189 + if (wsServer) { 190 + wsServer->loop(); 191 + } 151 192 } 152 193 153 194 void CrossPointWebServer::handleRoot() const { ··· 229 270 } 230 271 231 272 file.close(); 232 - yield(); // Yield to allow WiFi and other tasks to process during long scans 273 + yield(); // Yield to allow WiFi and other tasks to process during long scans 274 + esp_task_wdt_reset(); // Reset watchdog to prevent timeout on large directories 233 275 file = root.openNextFile(); 234 276 } 235 277 root.close(); ··· 301 343 static bool uploadSuccess = false; 302 344 static String uploadError = ""; 303 345 346 + // Upload write buffer - batches small writes into larger SD card operations 347 + // 4KB is a good balance: large enough to reduce syscall overhead, small enough 348 + // to keep individual write times short and avoid watchdog issues 349 + constexpr size_t UPLOAD_BUFFER_SIZE = 4096; // 4KB buffer 350 + static uint8_t uploadBuffer[UPLOAD_BUFFER_SIZE]; 351 + static size_t uploadBufferPos = 0; 352 + 353 + // Diagnostic counters for upload performance analysis 354 + static unsigned long uploadStartTime = 0; 355 + static unsigned long totalWriteTime = 0; 356 + static size_t writeCount = 0; 357 + 358 + static bool flushUploadBuffer() { 359 + if (uploadBufferPos > 0 && uploadFile) { 360 + esp_task_wdt_reset(); // Reset watchdog before potentially slow SD write 361 + const unsigned long writeStart = millis(); 362 + const size_t written = uploadFile.write(uploadBuffer, uploadBufferPos); 363 + totalWriteTime += millis() - writeStart; 364 + writeCount++; 365 + esp_task_wdt_reset(); // Reset watchdog after SD write 366 + 367 + if (written != uploadBufferPos) { 368 + Serial.printf("[%lu] [WEB] [UPLOAD] Buffer flush failed: expected %d, wrote %d\n", millis(), uploadBufferPos, 369 + written); 370 + uploadBufferPos = 0; 371 + return false; 372 + } 373 + uploadBufferPos = 0; 374 + } 375 + return true; 376 + } 377 + 304 378 void CrossPointWebServer::handleUpload() const { 305 - static unsigned long lastWriteTime = 0; 306 - static unsigned long uploadStartTime = 0; 307 379 static size_t lastLoggedSize = 0; 380 + 381 + // Reset watchdog at start of every upload callback - HTTP parsing can be slow 382 + esp_task_wdt_reset(); 308 383 309 384 // Safety check: ensure server is still valid 310 385 if (!running || !server) { ··· 315 390 const HTTPUpload& upload = server->upload(); 316 391 317 392 if (upload.status == UPLOAD_FILE_START) { 393 + // Reset watchdog - this is the critical 1% crash point 394 + esp_task_wdt_reset(); 395 + 318 396 uploadFileName = upload.filename; 319 397 uploadSize = 0; 320 398 uploadSuccess = false; 321 399 uploadError = ""; 322 400 uploadStartTime = millis(); 323 - lastWriteTime = millis(); 324 401 lastLoggedSize = 0; 402 + uploadBufferPos = 0; 403 + totalWriteTime = 0; 404 + writeCount = 0; 325 405 326 406 // Get upload path from query parameter (defaults to root if not specified) 327 407 // Note: We use query parameter instead of form data because multipart form ··· 348 428 if (!filePath.endsWith("/")) filePath += "/"; 349 429 filePath += uploadFileName; 350 430 351 - // Check if file already exists 431 + // Check if file already exists - SD operations can be slow 432 + esp_task_wdt_reset(); 352 433 if (SdMan.exists(filePath.c_str())) { 353 434 Serial.printf("[%lu] [WEB] [UPLOAD] Overwriting existing file: %s\n", millis(), filePath.c_str()); 435 + esp_task_wdt_reset(); 354 436 SdMan.remove(filePath.c_str()); 355 437 } 356 438 357 - // Open file for writing 439 + // Open file for writing - this can be slow due to FAT cluster allocation 440 + esp_task_wdt_reset(); 358 441 if (!SdMan.openFileForWrite("WEB", filePath, uploadFile)) { 359 442 uploadError = "Failed to create file on SD card"; 360 443 Serial.printf("[%lu] [WEB] [UPLOAD] FAILED to create file: %s\n", millis(), filePath.c_str()); 361 444 return; 362 445 } 446 + esp_task_wdt_reset(); 363 447 364 448 Serial.printf("[%lu] [WEB] [UPLOAD] File created successfully: %s\n", millis(), filePath.c_str()); 365 449 } else if (upload.status == UPLOAD_FILE_WRITE) { 366 450 if (uploadFile && uploadError.isEmpty()) { 367 - const unsigned long writeStartTime = millis(); 368 - const size_t written = uploadFile.write(upload.buf, upload.currentSize); 369 - const unsigned long writeEndTime = millis(); 370 - const unsigned long writeDuration = writeEndTime - writeStartTime; 451 + // Buffer incoming data and flush when buffer is full 452 + // This reduces SD card write operations and improves throughput 453 + const uint8_t* data = upload.buf; 454 + size_t remaining = upload.currentSize; 371 455 372 - if (written != upload.currentSize) { 373 - uploadError = "Failed to write to SD card - disk may be full"; 374 - uploadFile.close(); 375 - Serial.printf("[%lu] [WEB] [UPLOAD] WRITE ERROR - expected %d, wrote %d\n", millis(), upload.currentSize, 376 - written); 377 - } else { 378 - uploadSize += written; 456 + while (remaining > 0) { 457 + const size_t space = UPLOAD_BUFFER_SIZE - uploadBufferPos; 458 + const size_t toCopy = (remaining < space) ? remaining : space; 379 459 380 - // Log progress every 50KB or if write took >100ms 381 - if (uploadSize - lastLoggedSize >= 51200 || writeDuration > 100) { 382 - const unsigned long timeSinceStart = millis() - uploadStartTime; 383 - const unsigned long timeSinceLastWrite = millis() - lastWriteTime; 384 - const float kbps = (uploadSize / 1024.0) / (timeSinceStart / 1000.0); 460 + memcpy(uploadBuffer + uploadBufferPos, data, toCopy); 461 + uploadBufferPos += toCopy; 462 + data += toCopy; 463 + remaining -= toCopy; 385 464 386 - Serial.printf( 387 - "[%lu] [WEB] [UPLOAD] Progress: %d bytes (%.1f KB), %.1f KB/s, write took %lu ms, gap since last: %lu " 388 - "ms\n", 389 - millis(), uploadSize, uploadSize / 1024.0, kbps, writeDuration, timeSinceLastWrite); 390 - lastLoggedSize = uploadSize; 465 + // Flush buffer when full 466 + if (uploadBufferPos >= UPLOAD_BUFFER_SIZE) { 467 + if (!flushUploadBuffer()) { 468 + uploadError = "Failed to write to SD card - disk may be full"; 469 + uploadFile.close(); 470 + return; 471 + } 391 472 } 392 - lastWriteTime = millis(); 473 + } 474 + 475 + uploadSize += upload.currentSize; 476 + 477 + // Log progress every 100KB 478 + if (uploadSize - lastLoggedSize >= 102400) { 479 + const unsigned long elapsed = millis() - uploadStartTime; 480 + const float kbps = (elapsed > 0) ? (uploadSize / 1024.0) / (elapsed / 1000.0) : 0; 481 + Serial.printf("[%lu] [WEB] [UPLOAD] %d bytes (%.1f KB), %.1f KB/s, %d writes\n", millis(), uploadSize, 482 + uploadSize / 1024.0, kbps, writeCount); 483 + lastLoggedSize = uploadSize; 393 484 } 394 485 } 395 486 } else if (upload.status == UPLOAD_FILE_END) { 396 487 if (uploadFile) { 488 + // Flush any remaining buffered data 489 + if (!flushUploadBuffer()) { 490 + uploadError = "Failed to write final data to SD card"; 491 + } 397 492 uploadFile.close(); 398 493 399 494 if (uploadError.isEmpty()) { 400 495 uploadSuccess = true; 401 - Serial.printf("[%lu] [WEB] Upload complete: %s (%d bytes)\n", millis(), uploadFileName.c_str(), uploadSize); 496 + const unsigned long elapsed = millis() - uploadStartTime; 497 + const float avgKbps = (elapsed > 0) ? (uploadSize / 1024.0) / (elapsed / 1000.0) : 0; 498 + const float writePercent = (elapsed > 0) ? (totalWriteTime * 100.0 / elapsed) : 0; 499 + Serial.printf("[%lu] [WEB] [UPLOAD] Complete: %s (%d bytes in %lu ms, avg %.1f KB/s)\n", millis(), 500 + uploadFileName.c_str(), uploadSize, elapsed, avgKbps); 501 + Serial.printf("[%lu] [WEB] [UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)\n", millis(), 502 + writeCount, totalWriteTime, writePercent); 402 503 } 403 504 } 404 505 } else if (upload.status == UPLOAD_FILE_ABORTED) { 506 + uploadBufferPos = 0; // Discard buffered data 405 507 if (uploadFile) { 406 508 uploadFile.close(); 407 509 // Try to delete the incomplete file ··· 555 657 server->send(500, "text/plain", "Failed to delete item"); 556 658 } 557 659 } 660 + 661 + // WebSocket callback trampoline 662 + void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { 663 + if (wsInstance) { 664 + wsInstance->onWebSocketEvent(num, type, payload, length); 665 + } 666 + } 667 + 668 + // WebSocket event handler for fast binary uploads 669 + // Protocol: 670 + // 1. Client sends TEXT message: "START:<filename>:<size>:<path>" 671 + // 2. Client sends BINARY messages with file data chunks 672 + // 3. Server sends TEXT "PROGRESS:<received>:<total>" after each chunk 673 + // 4. Server sends TEXT "DONE" or "ERROR:<message>" when complete 674 + void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { 675 + switch (type) { 676 + case WStype_DISCONNECTED: 677 + Serial.printf("[%lu] [WS] Client %u disconnected\n", millis(), num); 678 + // Clean up any in-progress upload 679 + if (wsUploadInProgress && wsUploadFile) { 680 + wsUploadFile.close(); 681 + // Delete incomplete file 682 + String filePath = wsUploadPath; 683 + if (!filePath.endsWith("/")) filePath += "/"; 684 + filePath += wsUploadFileName; 685 + SdMan.remove(filePath.c_str()); 686 + Serial.printf("[%lu] [WS] Deleted incomplete upload: %s\n", millis(), filePath.c_str()); 687 + } 688 + wsUploadInProgress = false; 689 + break; 690 + 691 + case WStype_CONNECTED: { 692 + Serial.printf("[%lu] [WS] Client %u connected\n", millis(), num); 693 + break; 694 + } 695 + 696 + case WStype_TEXT: { 697 + // Parse control messages 698 + String msg = String((char*)payload); 699 + Serial.printf("[%lu] [WS] Text from client %u: %s\n", millis(), num, msg.c_str()); 700 + 701 + if (msg.startsWith("START:")) { 702 + // Parse: START:<filename>:<size>:<path> 703 + int firstColon = msg.indexOf(':', 6); 704 + int secondColon = msg.indexOf(':', firstColon + 1); 705 + 706 + if (firstColon > 0 && secondColon > 0) { 707 + wsUploadFileName = msg.substring(6, firstColon); 708 + wsUploadSize = msg.substring(firstColon + 1, secondColon).toInt(); 709 + wsUploadPath = msg.substring(secondColon + 1); 710 + wsUploadReceived = 0; 711 + wsUploadStartTime = millis(); 712 + 713 + // Ensure path is valid 714 + if (!wsUploadPath.startsWith("/")) wsUploadPath = "/" + wsUploadPath; 715 + if (wsUploadPath.length() > 1 && wsUploadPath.endsWith("/")) { 716 + wsUploadPath = wsUploadPath.substring(0, wsUploadPath.length() - 1); 717 + } 718 + 719 + // Build file path 720 + String filePath = wsUploadPath; 721 + if (!filePath.endsWith("/")) filePath += "/"; 722 + filePath += wsUploadFileName; 723 + 724 + Serial.printf("[%lu] [WS] Starting upload: %s (%d bytes) to %s\n", millis(), wsUploadFileName.c_str(), 725 + wsUploadSize, filePath.c_str()); 726 + 727 + // Check if file exists and remove it 728 + esp_task_wdt_reset(); 729 + if (SdMan.exists(filePath.c_str())) { 730 + SdMan.remove(filePath.c_str()); 731 + } 732 + 733 + // Open file for writing 734 + esp_task_wdt_reset(); 735 + if (!SdMan.openFileForWrite("WS", filePath, wsUploadFile)) { 736 + wsServer->sendTXT(num, "ERROR:Failed to create file"); 737 + wsUploadInProgress = false; 738 + return; 739 + } 740 + esp_task_wdt_reset(); 741 + 742 + wsUploadInProgress = true; 743 + wsServer->sendTXT(num, "READY"); 744 + } else { 745 + wsServer->sendTXT(num, "ERROR:Invalid START format"); 746 + } 747 + } 748 + break; 749 + } 750 + 751 + case WStype_BIN: { 752 + if (!wsUploadInProgress || !wsUploadFile) { 753 + wsServer->sendTXT(num, "ERROR:No upload in progress"); 754 + return; 755 + } 756 + 757 + // Write binary data directly to file 758 + esp_task_wdt_reset(); 759 + size_t written = wsUploadFile.write(payload, length); 760 + esp_task_wdt_reset(); 761 + 762 + if (written != length) { 763 + wsUploadFile.close(); 764 + wsUploadInProgress = false; 765 + wsServer->sendTXT(num, "ERROR:Write failed - disk full?"); 766 + return; 767 + } 768 + 769 + wsUploadReceived += written; 770 + 771 + // Send progress update (every 64KB or at end) 772 + static size_t lastProgressSent = 0; 773 + if (wsUploadReceived - lastProgressSent >= 65536 || wsUploadReceived >= wsUploadSize) { 774 + String progress = "PROGRESS:" + String(wsUploadReceived) + ":" + String(wsUploadSize); 775 + wsServer->sendTXT(num, progress); 776 + lastProgressSent = wsUploadReceived; 777 + } 778 + 779 + // Check if upload complete 780 + if (wsUploadReceived >= wsUploadSize) { 781 + wsUploadFile.close(); 782 + wsUploadInProgress = false; 783 + 784 + unsigned long elapsed = millis() - wsUploadStartTime; 785 + float kbps = (elapsed > 0) ? (wsUploadSize / 1024.0) / (elapsed / 1000.0) : 0; 786 + 787 + Serial.printf("[%lu] [WS] Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), 788 + wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps); 789 + 790 + wsServer->sendTXT(num, "DONE"); 791 + lastProgressSent = 0; 792 + } 793 + break; 794 + } 795 + 796 + default: 797 + break; 798 + } 799 + }
+7
src/network/CrossPointWebServer.h
··· 1 1 #pragma once 2 2 3 3 #include <WebServer.h> 4 + #include <WebSocketsServer.h> 4 5 5 6 #include <vector> 6 7 ··· 34 35 35 36 private: 36 37 std::unique_ptr<WebServer> server = nullptr; 38 + std::unique_ptr<WebSocketsServer> wsServer = nullptr; 37 39 bool running = false; 38 40 bool apMode = false; // true when running in AP mode, false for STA mode 39 41 uint16_t port = 80; 42 + uint16_t wsPort = 81; // WebSocket port 43 + 44 + // WebSocket upload state 45 + void onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t length); 46 + static void wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length); 40 47 41 48 // File scanning 42 49 void scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const;
+187 -37
src/network/html/FilesPage.html
··· 816 816 } 817 817 818 818 let failedUploadsGlobal = []; 819 + let wsConnection = null; 820 + const WS_PORT = 81; 821 + const WS_CHUNK_SIZE = 4096; // 4KB chunks - smaller for ESP32 stability 822 + 823 + // Get WebSocket URL based on current page location 824 + function getWsUrl() { 825 + const host = window.location.hostname; 826 + return `ws://${host}:${WS_PORT}/`; 827 + } 828 + 829 + // Upload file via WebSocket (faster, binary protocol) 830 + function uploadFileWebSocket(file, onProgress, onComplete, onError) { 831 + return new Promise((resolve, reject) => { 832 + const ws = new WebSocket(getWsUrl()); 833 + let uploadStarted = false; 834 + let sendingChunks = false; 835 + 836 + ws.binaryType = 'arraybuffer'; 837 + 838 + ws.onopen = function() { 839 + console.log('[WS] Connected, starting upload:', file.name); 840 + // Send start message: START:<filename>:<size>:<path> 841 + ws.send(`START:${file.name}:${file.size}:${currentPath}`); 842 + }; 843 + 844 + ws.onmessage = async function(event) { 845 + const msg = event.data; 846 + console.log('[WS] Message:', msg); 847 + 848 + if (msg === 'READY') { 849 + uploadStarted = true; 850 + sendingChunks = true; 851 + 852 + // Small delay to let connection stabilize 853 + await new Promise(r => setTimeout(r, 50)); 854 + 855 + try { 856 + // Send file in chunks 857 + const totalSize = file.size; 858 + let offset = 0; 859 + 860 + while (offset < totalSize && ws.readyState === WebSocket.OPEN) { 861 + const chunkSize = Math.min(WS_CHUNK_SIZE, totalSize - offset); 862 + const chunk = file.slice(offset, offset + chunkSize); 863 + const buffer = await chunk.arrayBuffer(); 864 + 865 + // Wait for buffer to clear - more aggressive backpressure 866 + while (ws.bufferedAmount > WS_CHUNK_SIZE * 2 && ws.readyState === WebSocket.OPEN) { 867 + await new Promise(r => setTimeout(r, 5)); 868 + } 869 + 870 + if (ws.readyState !== WebSocket.OPEN) { 871 + throw new Error('WebSocket closed during upload'); 872 + } 873 + 874 + ws.send(buffer); 875 + offset += chunkSize; 876 + 877 + // Update local progress - cap at 95% since server still needs to write 878 + // Final 100% shown when server confirms DONE 879 + if (onProgress) { 880 + const cappedOffset = Math.min(offset, Math.floor(totalSize * 0.95)); 881 + onProgress(cappedOffset, totalSize); 882 + } 883 + } 884 + 885 + sendingChunks = false; 886 + console.log('[WS] All chunks sent, waiting for DONE'); 887 + } catch (err) { 888 + console.error('[WS] Error sending chunks:', err); 889 + sendingChunks = false; 890 + ws.close(); 891 + reject(err); 892 + } 893 + } else if (msg.startsWith('PROGRESS:')) { 894 + // Server confirmed progress - log for debugging but don't update UI 895 + // (local progress is smoother, server progress causes jumping) 896 + console.log('[WS] Server progress:', msg); 897 + } else if (msg === 'DONE') { 898 + // Show 100% when server confirms completion 899 + if (onProgress) onProgress(file.size, file.size); 900 + ws.close(); 901 + if (onComplete) onComplete(); 902 + resolve(); 903 + } else if (msg.startsWith('ERROR:')) { 904 + const error = msg.substring(6); 905 + ws.close(); 906 + if (onError) onError(error); 907 + reject(new Error(error)); 908 + } 909 + }; 910 + 911 + ws.onerror = function(event) { 912 + console.error('[WS] Error:', event); 913 + if (!uploadStarted) { 914 + reject(new Error('WebSocket connection failed')); 915 + } else if (!sendingChunks) { 916 + reject(new Error('WebSocket error during upload')); 917 + } 918 + }; 919 + 920 + ws.onclose = function(event) { 921 + console.log('[WS] Connection closed, code:', event.code, 'reason:', event.reason); 922 + if (sendingChunks) { 923 + reject(new Error('WebSocket closed unexpectedly')); 924 + } 925 + }; 926 + }); 927 + } 928 + 929 + // Upload file via HTTP (fallback method) 930 + function uploadFileHTTP(file, onProgress, onComplete, onError) { 931 + return new Promise((resolve, reject) => { 932 + const formData = new FormData(); 933 + formData.append('file', file); 934 + 935 + const xhr = new XMLHttpRequest(); 936 + xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true); 937 + 938 + xhr.upload.onprogress = function(e) { 939 + if (e.lengthComputable && onProgress) { 940 + onProgress(e.loaded, e.total); 941 + } 942 + }; 943 + 944 + xhr.onload = function() { 945 + if (xhr.status === 200) { 946 + if (onComplete) onComplete(); 947 + resolve(); 948 + } else { 949 + const error = xhr.responseText || 'Upload failed'; 950 + if (onError) onError(error); 951 + reject(new Error(error)); 952 + } 953 + }; 954 + 955 + xhr.onerror = function() { 956 + const error = 'Network error'; 957 + if (onError) onError(error); 958 + reject(new Error(error)); 959 + }; 960 + 961 + xhr.send(formData); 962 + }); 963 + } 819 964 820 965 function uploadFile() { 821 966 const fileInput = document.getElementById('fileInput'); ··· 836 981 837 982 let currentIndex = 0; 838 983 const failedFiles = []; 984 + let useWebSocket = true; // Try WebSocket first 839 985 840 - function uploadNextFile() { 986 + async function uploadNextFile() { 841 987 if (currentIndex >= files.length) { 842 988 // All files processed - show summary 843 989 if (failedFiles.length === 0) { ··· 845 991 progressText.textContent = 'All uploads complete!'; 846 992 setTimeout(() => { 847 993 closeUploadModal(); 848 - hydrate(); // Refresh file list instead of reloading 994 + hydrate(); 849 995 }, 1000); 850 996 } else { 851 997 progressFill.style.backgroundColor = '#e74c3c'; 852 998 const failedList = failedFiles.map(f => f.name).join(', '); 853 999 progressText.textContent = `${files.length - failedFiles.length}/${files.length} uploaded. Failed: ${failedList}`; 854 - 855 - // Store failed files globally and show banner 856 1000 failedUploadsGlobal = failedFiles; 857 - 858 1001 setTimeout(() => { 859 1002 closeUploadModal(); 860 1003 showFailedUploadsBanner(); 861 - hydrate(); // Refresh file list to show successfully uploaded files 1004 + hydrate(); 862 1005 }, 2000); 863 1006 } 864 1007 return; 865 1008 } 866 1009 867 1010 const file = files[currentIndex]; 868 - const formData = new FormData(); 869 - formData.append('file', file); 870 - 871 - const xhr = new XMLHttpRequest(); 872 - // Include path as query parameter since multipart form data doesn't make 873 - // form fields available until after file upload completes 874 - xhr.open('POST', '/upload?path=' + encodeURIComponent(currentPath), true); 875 - 876 1011 progressFill.style.width = '0%'; 877 - progressFill.style.backgroundColor = '#4caf50'; 878 - progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})`; 1012 + progressFill.style.backgroundColor = '#27ae60'; 1013 + const methodText = useWebSocket ? ' [WS]' : ' [HTTP]'; 1014 + progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText}`; 879 1015 880 - xhr.upload.onprogress = function (e) { 881 - if (e.lengthComputable) { 882 - const percent = Math.round((e.loaded / e.total) * 100); 883 - progressFill.style.width = percent + '%'; 884 - progressText.textContent = 885 - `Uploading ${file.name} (${currentIndex + 1}/${files.length}) — ${percent}%`; 886 - } 1016 + const onProgress = (loaded, total) => { 1017 + const percent = Math.round((loaded / total) * 100); 1018 + progressFill.style.width = percent + '%'; 1019 + const speed = ''; // Could calculate speed here 1020 + progressText.textContent = `Uploading ${file.name} (${currentIndex + 1}/${files.length})${methodText} — ${percent}%`; 887 1021 }; 888 1022 889 - xhr.onload = function () { 890 - if (xhr.status === 200) { 891 - currentIndex++; 892 - uploadNextFile(); // upload next file 893 - } else { 894 - // Track failure and continue with next file 895 - failedFiles.push({ name: file.name, error: xhr.responseText, file: file }); 896 - currentIndex++; 897 - uploadNextFile(); 898 - } 1023 + const onComplete = () => { 1024 + currentIndex++; 1025 + uploadNextFile(); 899 1026 }; 900 1027 901 - xhr.onerror = function () { 902 - // Track network error and continue with next file 903 - failedFiles.push({ name: file.name, error: 'network error', file: file }); 1028 + const onError = (error) => { 1029 + failedFiles.push({ name: file.name, error: error, file: file }); 904 1030 currentIndex++; 905 1031 uploadNextFile(); 906 1032 }; 907 1033 908 - xhr.send(formData); 1034 + try { 1035 + if (useWebSocket) { 1036 + await uploadFileWebSocket(file, onProgress, null, null); 1037 + onComplete(); 1038 + } else { 1039 + await uploadFileHTTP(file, onProgress, null, null); 1040 + onComplete(); 1041 + } 1042 + } catch (error) { 1043 + console.error('Upload error:', error); 1044 + if (useWebSocket && error.message === 'WebSocket connection failed') { 1045 + // Fall back to HTTP for all subsequent uploads 1046 + console.log('WebSocket failed, falling back to HTTP'); 1047 + useWebSocket = false; 1048 + // Retry this file with HTTP 1049 + try { 1050 + await uploadFileHTTP(file, onProgress, null, null); 1051 + onComplete(); 1052 + } catch (httpError) { 1053 + onError(httpError.message); 1054 + } 1055 + } else { 1056 + onError(error.message); 1057 + } 1058 + } 909 1059 } 910 1060 911 1061 uploadNextFile();