From e9e9ef68da4628e32271ca583196f6418941515f Mon Sep 17 00:00:00 2001 From: cottongin Date: Sun, 25 Jan 2026 13:43:04 -0500 Subject: [PATCH] adds initial support for companion app --- docs/companion-app-deep-link-API.md | 309 ++++++++++++++++++ .../network/CrossPointWebServerActivity.cpp | 236 ++++++++++++- .../network/CrossPointWebServerActivity.h | 17 + src/network/CrossPointWebServer.cpp | 15 + src/network/CrossPointWebServer.h | 17 + 5 files changed, 584 insertions(+), 10 deletions(-) create mode 100644 docs/companion-app-deep-link-API.md diff --git a/docs/companion-app-deep-link-API.md b/docs/companion-app-deep-link-API.md new file mode 100644 index 0000000..c9b5079 --- /dev/null +++ b/docs/companion-app-deep-link-API.md @@ -0,0 +1,309 @@ +# CrossPoint Companion Deep Link API + +This document describes the deep link functionality that allows the CrossPoint Companion Android app to be launched from QR codes displayed on CrossPoint e-reader devices. + +## Overview + +The CrossPoint firmware can generate QR codes containing deep link URLs. When scanned with a mobile device, these URLs launch the companion app directly to a specific tab and optionally auto-connect to the device. + +## URL Scheme + +``` +crosspoint://? +``` + +### Components + +| Component | Description | +|-----------|-------------| +| `crosspoint://` | Custom URL scheme registered by the app | +| `` | Target tab in the app (see [Path Mapping](#path-mapping)) | +| `` | Optional device connection parameters | + +## Path Mapping + +The URL path determines which tab the app navigates to: + +| Path | App Tab | Description | +|------|---------|-------------| +| `files` | Device | File browser for device storage | +| `library` | Library | Local book library | +| `lists` | Lists | Reading lists management | +| `settings` | Settings | App settings | + +**Note:** Unknown paths default to the Library tab. + +## Query Parameters + +Query parameters provide device connection information for automatic connection: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `host` | string | *(required for auto-connect)* | IP address or hostname of the device | +| `port` | integer | `80` | HTTP API port | +| `wsPort` | integer | `81` | WebSocket port for file uploads | + +## URL Examples + +### Basic Navigation (No Auto-Connect) + +Navigate to a specific tab without connecting to a device: + +``` +crosspoint://files +crosspoint://library +crosspoint://lists +crosspoint://settings +``` + +### Auto-Connect to Device + +Navigate to Device tab and auto-connect: + +``` +crosspoint://files?host=192.168.1.100 +crosspoint://files?host=192.168.1.100&port=80&wsPort=81 +``` + +### Custom Ports + +Connect to a device with non-default ports: + +``` +crosspoint://files?host=192.168.1.100&port=8080&wsPort=8081 +``` + +### Hostname Instead of IP + +``` +crosspoint://files?host=crosspoint.local&port=80&wsPort=81 +``` + +## Firmware Implementation + +### QR Code Generation + +The CrossPoint firmware should generate QR codes containing the deep link URL. Example format: + +``` +crosspoint://files?host=&port=&wsPort= +``` + +Where: +- `` is the device's current IP address (e.g., from WiFi connection) +- `` is the HTTP API port (default: 80) +- `` is the WebSocket port (default: 81) + +### Example Firmware Code (C++) + +```cpp +String generateDeepLinkUrl(const char* path = "files") { + String url = "crosspoint://"; + url += path; + url += "?host="; + url += WiFi.localIP().toString(); + url += "&port="; + url += String(HTTP_PORT); // e.g., 80 + url += "&wsPort="; + url += String(WS_PORT); // e.g., 81 + return url; +} + +// Generate QR code with: +// String url = generateDeepLinkUrl("files"); +// displayQRCode(url); +``` + +## App Behavior + +### Launch Scenarios + +#### 1. App Not Running + +When the app is launched via deep link: +1. App starts and parses the deep link URL +2. Navigates to the target tab +3. If device connection info is present and target is "files": + - Checks for existing device with matching IP + - If found: uses existing device (preserving user's custom name) + - If not found: creates temporary connection + - Attempts to connect automatically + +#### 2. App Already Running + +When a deep link is received while the app is open: +1. `onNewIntent` receives the new URL +2. Navigates to the target tab +3. Handles device connection (same as above) + +### Device Matching Logic + +When connecting via deep link: + +``` +1. Look up device by IP address in database +2. If device exists: + a. Check if ports match + b. If ports differ, update the stored device with new ports + c. Connect using the existing device (preserves custom name) +3. If device doesn't exist: + a. Create temporary Device object (not saved to database) + b. Connect using temporary device + c. Display as "CrossPoint ()" +``` + +### Error Handling + +| Scenario | Behavior | +|----------|----------| +| Malformed URL | App opens to Library tab (default) | +| Unknown path | App opens to Library tab with warning logged | +| Invalid host format | Navigation succeeds, no auto-connect | +| Invalid port values | Default ports used (80, 81) | +| Connection failure | Error message displayed, user can retry | +| Device unreachable | Error message with device IP shown | + +## Android Implementation Details + +### Intent Filter (AndroidManifest.xml) + +```xml + + + + + + +``` + +### Key Classes + +| Class | Purpose | +|-------|---------| +| `DeepLinkParser` | Parses URI into `DeepLinkData` | +| `DeepLinkData` | Data class holding parsed deep link info | +| `DeviceConnectionInfo` | Data class for host/port/wsPort | +| `MainActivity` | Handles incoming intents | +| `CrossPointApp` | Routes navigation based on deep link | +| `DeviceBrowserViewModel` | Handles `connectFromDeepLink()` | + +### Data Flow + +``` +QR Code Scan + │ + ▼ +Android Intent (ACTION_VIEW) + │ + ▼ +MainActivity.onCreate() / onNewIntent() + │ + ▼ +DeepLinkParser.parse(uri) + │ + ▼ +DeepLinkData { targetTab, deviceConnection? } + │ + ▼ +CrossPointApp (LaunchedEffect) + │ + ├─► Navigate to targetTab + │ + └─► If targetTab == Device && deviceConnection != null + │ + ▼ + DeviceBrowserScreen + │ + ▼ + DeviceBrowserViewModel.connectFromDeepLink() + │ + ├─► Check existing device by IP + ├─► Update ports if needed + └─► Connect and load files +``` + +## Validation Rules + +### Host Validation + +Valid hosts: +- IPv4 addresses: `192.168.1.100`, `10.0.0.1` +- Hostnames: `crosspoint.local`, `my-device` + +Invalid hosts (rejected): +- Empty strings +- Malformed IPs: `192.168.1.256`, `192.168.1` +- IPs with invalid octets + +### Port Validation + +- Valid range: 1-65535 +- Out-of-range values default to 80 (HTTP) or 81 (WebSocket) +- Non-numeric values default to standard ports + +## Testing + +### Manual Testing with ADB + +Test deep links without a QR code using ADB: + +```bash +# Basic navigation +adb shell am start -a android.intent.action.VIEW -d "crosspoint://files" +adb shell am start -a android.intent.action.VIEW -d "crosspoint://library" + +# With device connection +adb shell am start -a android.intent.action.VIEW -d "crosspoint://files?host=192.168.1.100" +adb shell am start -a android.intent.action.VIEW -d "crosspoint://files?host=192.168.1.100&port=80&wsPort=81" + +# Test while app is running (onNewIntent) +adb shell am start -a android.intent.action.VIEW -d "crosspoint://settings" +``` + +### Test Cases + +1. **Valid deep link with connection info** + - URL: `crosspoint://files?host=192.168.1.100&port=80&wsPort=81` + - Expected: Opens Device tab, auto-connects to device + +2. **Valid deep link without connection info** + - URL: `crosspoint://files` + - Expected: Opens Device tab, shows device selection + +3. **Unknown path** + - URL: `crosspoint://unknown` + - Expected: Opens Library tab (default) + +4. **Missing host parameter** + - URL: `crosspoint://files?port=80` + - Expected: Opens Device tab, no auto-connect + +5. **Invalid host format** + - URL: `crosspoint://files?host=invalid..host` + - Expected: Opens Device tab, no auto-connect + +6. **Device already exists in database** + - Precondition: Device with IP 192.168.1.100 saved as "My Reader" + - URL: `crosspoint://files?host=192.168.1.100` + - Expected: Connects using "My Reader" name + +7. **Existing device with different ports** + - Precondition: Device saved with port=80, wsPort=81 + - URL: `crosspoint://files?host=192.168.1.100&port=8080&wsPort=8081` + - Expected: Updates device ports, then connects + +## Security Considerations + +1. **Local Network Only**: Deep links should only contain local network addresses. The app does not validate this, but firmware should only generate URLs with local IPs. + +2. **No Authentication**: The deep link does not include authentication. Device security relies on network-level access control. + +3. **Temporary Devices**: Devices created from deep links (when no matching device exists) are not persisted, preventing automatic accumulation of device entries. + +4. **No Sensitive Data**: Deep link URLs should not contain sensitive information as QR codes can be photographed. + +## Changelog + +| Version | Changes | +|---------|---------| +| 1.0.0 | Initial deep link support with `crosspoint://` scheme | diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 35ad58b..9344fd9 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -46,6 +46,8 @@ void CrossPointWebServerActivity::onEnter() { connectedIP.clear(); connectedSSID.clear(); lastHandleClientTime = 0; + currentScreen = FileTransferScreen::WEB_BROWSER; + lastStatsRefresh = 0; updateRequired = true; xTaskCreate(&CrossPointWebServerActivity::taskTrampoline, "WebServerActivityTask", @@ -339,6 +341,48 @@ void CrossPointWebServerActivity::loop() { lastHandleClientTime = millis(); } + // Handle screen switching with arrow buttons + if (mappedInput.wasPressed(MappedInputManager::Button::PageForward)) { + // Cycle to next screen: WEB_BROWSER -> COMPANION_APP -> COMPANION_APP_LIBRARY -> WEB_BROWSER + switch (currentScreen) { + case FileTransferScreen::WEB_BROWSER: + currentScreen = FileTransferScreen::COMPANION_APP; + break; + case FileTransferScreen::COMPANION_APP: + currentScreen = FileTransferScreen::COMPANION_APP_LIBRARY; + break; + case FileTransferScreen::COMPANION_APP_LIBRARY: + currentScreen = FileTransferScreen::WEB_BROWSER; + break; + } + updateRequired = true; + Serial.printf("[%lu] [WEBACT] Switched to screen: %d\n", millis(), static_cast(currentScreen)); + } + + if (mappedInput.wasPressed(MappedInputManager::Button::PageBack)) { + // Cycle to previous screen: WEB_BROWSER -> COMPANION_APP_LIBRARY -> COMPANION_APP -> WEB_BROWSER + switch (currentScreen) { + case FileTransferScreen::WEB_BROWSER: + currentScreen = FileTransferScreen::COMPANION_APP_LIBRARY; + break; + case FileTransferScreen::COMPANION_APP: + currentScreen = FileTransferScreen::WEB_BROWSER; + break; + case FileTransferScreen::COMPANION_APP_LIBRARY: + currentScreen = FileTransferScreen::COMPANION_APP; + break; + } + updateRequired = true; + Serial.printf("[%lu] [WEBACT] Switched to screen: %d\n", millis(), static_cast(currentScreen)); + } + + // Refresh stats every 30 seconds + if (millis() - lastStatsRefresh >= 30000) { + lastStatsRefresh = millis(); + updateRequired = true; + Serial.printf("[%lu] [WEBACT] Stats refresh triggered\n", millis()); + } + // Handle exit on Back button (also check outside loop) if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onGoBack(); @@ -396,11 +440,111 @@ void drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std } } -void CrossPointWebServerActivity::renderServerRunning() const { - // Use consistent line spacing - constexpr int LINE_SPACING = 28; // Space between lines +// Helper to format bytes into human-readable sizes +std::string formatBytes(size_t bytes) { + if (bytes < 1024) { + return std::to_string(bytes) + " B"; + } else if (bytes < 1024 * 1024) { + char buf[32]; + snprintf(buf, sizeof(buf), "%.1f KB", bytes / 1024.0); + return buf; + } else { + char buf[32]; + snprintf(buf, sizeof(buf), "%.1f MB", bytes / (1024.0 * 1024.0)); + return buf; + } +} - renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer", true, EpdFontFamily::BOLD); +// Helper to format uptime in human-readable format +std::string formatUptime(unsigned long seconds) { + if (seconds < 60) { + return std::to_string(seconds) + "s"; + } else if (seconds < 3600) { + unsigned long mins = seconds / 60; + unsigned long secs = seconds % 60; + return std::to_string(mins) + "m " + std::to_string(secs) + "s"; + } else { + unsigned long hours = seconds / 3600; + unsigned long mins = (seconds % 3600) / 60; + return std::to_string(hours) + "h " + std::to_string(mins) + "m"; + } +} + +std::string CrossPointWebServerActivity::getCompanionAppUrl() const { + // Generate deep link URL for companion Android app - Device/Files tab + // Format: crosspoint://files?host=&port=&wsPort= + std::string url = "crosspoint://files?host=" + connectedIP; + if (webServer) { + url += "&port=" + std::to_string(webServer->getPort()); + url += "&wsPort=" + std::to_string(webServer->getWsPort()); + } + return url; +} + +std::string CrossPointWebServerActivity::getCompanionAppLibraryUrl() const { + // Generate deep link URL for companion Android app - Library tab + // Format: crosspoint://library?host=&port=&wsPort= + std::string url = "crosspoint://library?host=" + connectedIP; + if (webServer) { + url += "&port=" + std::to_string(webServer->getPort()); + url += "&wsPort=" + std::to_string(webServer->getWsPort()); + } + return url; +} + +void CrossPointWebServerActivity::renderServerRunning() const { + // Dispatch to the appropriate screen + switch (currentScreen) { + case FileTransferScreen::WEB_BROWSER: + renderWebBrowserScreen(); + break; + case FileTransferScreen::COMPANION_APP: + renderCompanionAppScreen(); + break; + case FileTransferScreen::COMPANION_APP_LIBRARY: + renderCompanionAppLibraryScreen(); + break; + } + + // Render stats bar at the bottom (above button hints) + renderStats(); + + // Draw button hints with arrow navigation + const auto labels = mappedInput.mapLabels("« Exit", "", "«", "»"); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} + +void CrossPointWebServerActivity::renderStats() const { + if (!webServer) return; + + const int screenHeight = renderer.getScreenHeight(); + const int statsY = screenHeight - 70; // Position above button hints + + // Get stats from web server + const unsigned long uptime = webServer->getServerUptime(); + const size_t bytesUp = webServer->getTotalBytesUploaded(); + const size_t bytesDown = webServer->getTotalBytesDownloaded(); + const size_t filesUp = webServer->getTotalFilesUploaded(); + const size_t filesDown = webServer->getTotalFilesDownloaded(); + + // Format stats string + std::string statsLine = "Up: " + formatUptime(uptime); + statsLine += " | Recv: " + formatBytes(bytesUp); + if (filesUp > 0) { + statsLine += " (" + std::to_string(filesUp) + ")"; + } + statsLine += " | Sent: " + formatBytes(bytesDown); + if (filesDown > 0) { + statsLine += " (" + std::to_string(filesDown) + ")"; + } + + renderer.drawCenteredText(SMALL_FONT_ID, statsY, statsLine.c_str()); +} + +void CrossPointWebServerActivity::renderWebBrowserScreen() const { + constexpr int LINE_SPACING = 28; + + renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer - Web Browser", true, EpdFontFamily::BOLD); if (isApMode) { // AP mode display - center the content block @@ -415,7 +559,7 @@ void CrossPointWebServerActivity::renderServerRunning() const { renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, "or scan QR code with your phone to connect to Wifi."); - // Show QR code for URL + // Show QR code for WiFi config const std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;"; drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 4, wifiConfig); @@ -433,8 +577,8 @@ void CrossPointWebServerActivity::renderServerRunning() const { renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 6, "or scan QR code with your phone:"); drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 7, hostnameUrl); } else { - // STA mode display (original behavior) - const int startY = 65; + // STA mode display + const int startY = 55; std::string ssidInfo = "Network: " + connectedSSID; if (ssidInfo.length() > 28) { @@ -455,11 +599,83 @@ void CrossPointWebServerActivity::renderServerRunning() const { renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser"); + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "or scan QR code with your phone:"); + // Show QR code for URL drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 6, webInfo); - renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "or scan QR code with your phone:"); + } +} + +void CrossPointWebServerActivity::renderCompanionAppScreen() const { + constexpr int LINE_SPACING = 28; + + renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer - Companion App", true, EpdFontFamily::BOLD); + + const int startY = 55; + + // Show network info + std::string ssidInfo = "Network: " + connectedSSID; + if (ssidInfo.length() > 28) { + ssidInfo.replace(25, ssidInfo.length() - 25, "..."); + } + renderer.drawCenteredText(UI_10_FONT_ID, startY, ssidInfo.c_str()); + + if (isApMode) { + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING, + "Connect to this WiFi first, then scan QR code"); + } else { + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING, "Scan QR code with the CrossPoint Companion app"); } - const auto labels = mappedInput.mapLabels("« Exit", "", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + // Show companion app URL prominently + const std::string appUrl = getCompanionAppUrl(); + renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 2, appUrl.c_str(), true, EpdFontFamily::BOLD); + + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, "or scan QR code:"); + + // Show QR code for app URL (centered) + drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 4, appUrl); + + // Show HTTP URL for reference (smaller) + const int refY = startY + LINE_SPACING * 4 + 6 * 33 + 15; // Below QR code + renderer.drawCenteredText(SMALL_FONT_ID, refY, "Opens to: Device file browser"); + std::string webUrl = "http://" + connectedIP + "/"; + renderer.drawCenteredText(SMALL_FONT_ID, refY + 20, webUrl.c_str()); +} + +void CrossPointWebServerActivity::renderCompanionAppLibraryScreen() const { + constexpr int LINE_SPACING = 28; + + renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer - Library Link", true, EpdFontFamily::BOLD); + + const int startY = 55; + + // Show network info + std::string ssidInfo = "Network: " + connectedSSID; + if (ssidInfo.length() > 28) { + ssidInfo.replace(25, ssidInfo.length() - 25, "..."); + } + renderer.drawCenteredText(UI_10_FONT_ID, startY, ssidInfo.c_str()); + + if (isApMode) { + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING, + "Connect to this WiFi first, then scan QR code"); + } else { + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING, "Scan QR code with the CrossPoint Companion app"); + } + + // Show companion app URL prominently + const std::string appUrl = getCompanionAppLibraryUrl(); + renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 2, appUrl.c_str(), true, EpdFontFamily::BOLD); + + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, "or scan QR code:"); + + // Show QR code for app URL (centered) + drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 4, appUrl); + + // Show what this link opens to + const int refY = startY + LINE_SPACING * 4 + 6 * 33 + 15; // Below QR code + renderer.drawCenteredText(SMALL_FONT_ID, refY, "Opens to: Your local Library"); + std::string webUrl = "http://" + connectedIP + "/"; + renderer.drawCenteredText(SMALL_FONT_ID, refY + 20, webUrl.c_str()); } diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h index 775a247..03f9e63 100644 --- a/src/activities/network/CrossPointWebServerActivity.h +++ b/src/activities/network/CrossPointWebServerActivity.h @@ -20,6 +20,13 @@ enum class WebServerActivityState { SHUTTING_DOWN // Shutting down server and WiFi }; +// File transfer screen tabs +enum class FileTransferScreen { + WEB_BROWSER, // Default screen with HTTP URL and QR code + COMPANION_APP, // Screen with crosspoint://files deep link URL and QR code + COMPANION_APP_LIBRARY // Screen with crosspoint://library deep link URL and QR code +}; + /** * CrossPointWebServerActivity is the entry point for file transfer functionality. * It: @@ -51,10 +58,20 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity { // Performance monitoring unsigned long lastHandleClientTime = 0; + // Screen navigation + FileTransferScreen currentScreen = FileTransferScreen::WEB_BROWSER; + unsigned long lastStatsRefresh = 0; + static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void render() const; void renderServerRunning() const; + void renderWebBrowserScreen() const; + void renderCompanionAppScreen() const; + void renderCompanionAppLibraryScreen() const; + void renderStats() const; + std::string getCompanionAppUrl() const; + std::string getCompanionAppLibraryUrl() const; void onNetworkModeSelected(NetworkMode mode); void onWifiSelectionComplete(bool connected); diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 8f5d844..7d5f337 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -138,6 +138,7 @@ void CrossPointWebServer::begin() { Serial.printf("[%lu] [WEB] WebSocket server started\n", millis()); running = true; + serverStartTime = millis(); Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port); // Show the correct IP based on network mode @@ -557,6 +558,10 @@ void CrossPointWebServer::handleUpload() const { Serial.printf("[%lu] [WEB] [UPLOAD] Diagnostics: %d writes, total write time: %lu ms (%.1f%%)\n", millis(), writeCount, totalWriteTime, writePercent); + // Update traffic statistics + totalBytesUploaded += uploadSize; + totalFilesUploaded++; + // Clear epub cache to prevent stale metadata issues when overwriting files String filePath = uploadPath; if (!filePath.endsWith("/")) filePath += "/"; @@ -940,6 +945,10 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* Serial.printf("[%lu] [WS] Upload complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), wsUploadFileName.c_str(), wsUploadSize, elapsed, kbps); + // Update traffic statistics + totalBytesUploaded += wsUploadSize; + totalFilesUploaded++; + // Clear epub cache to prevent stale metadata issues when overwriting files String filePath = wsUploadPath; if (!filePath.endsWith("/")) filePath += "/"; @@ -1052,6 +1061,12 @@ void CrossPointWebServer::handleDownload() const { const float kbps = (elapsed > 0) ? (totalSent / 1024.0) / (elapsed / 1000.0) : 0; Serial.printf("[%lu] [WEB] Download complete: %s (%d bytes in %lu ms, %.1f KB/s)\n", millis(), filename.c_str(), totalSent, elapsed, kbps); + + // Update traffic statistics (only if download completed successfully) + if (totalSent == fileSize) { + totalBytesDownloaded += totalSent; + totalFilesDownloaded++; + } } void CrossPointWebServer::handleRename() const { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index edf5b3e..e87aac8 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -33,6 +33,16 @@ class CrossPointWebServer { // Get the port number uint16_t getPort() const { return port; } + // Get the WebSocket port number + uint16_t getWsPort() const { return wsPort; } + + // Traffic statistics + unsigned long getServerUptime() const { return running ? (millis() - serverStartTime) / 1000 : 0; } + size_t getTotalBytesUploaded() const { return totalBytesUploaded; } + size_t getTotalBytesDownloaded() const { return totalBytesDownloaded; } + size_t getTotalFilesUploaded() const { return totalFilesUploaded; } + size_t getTotalFilesDownloaded() const { return totalFilesDownloaded; } + private: std::unique_ptr server = nullptr; std::unique_ptr wsServer = nullptr; @@ -41,6 +51,13 @@ class CrossPointWebServer { uint16_t port = 80; uint16_t wsPort = 81; // WebSocket port + // Traffic statistics (mutable to allow updating from const handlers) + mutable size_t totalBytesUploaded = 0; + mutable size_t totalBytesDownloaded = 0; + mutable size_t totalFilesUploaded = 0; + mutable size_t totalFilesDownloaded = 0; + unsigned long serverStartTime = 0; + // 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);