diff --git a/platformio.ini b/platformio.ini index 75d1a77b..afa720da 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,6 +45,7 @@ lib_deps = SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager ArduinoJson @ 7.4.2 QRCode @ 0.0.1 + xreef/SimpleFTPServer @ 3.0.1 [env:default] extends = base diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index d304c4e4..2ea2adfc 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -12,7 +12,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 13; +constexpr uint8_t SETTINGS_COUNT = 17; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -40,6 +40,10 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, paragraphAlignment); serialization::writePod(outputFile, sleepTimeout); serialization::writePod(outputFile, refreshFrequency); + serialization::writeString(outputFile, ftpUsername); + serialization::writeString(outputFile, ftpPassword); + serialization::writeString(outputFile, httpUsername); + serialization::writeString(outputFile, httpPassword); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -92,6 +96,14 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, refreshFrequency); if (++settingsRead >= fileSettingsCount) break; + serialization::readString(inputFile, ftpUsername); + if (++settingsRead >= fileSettingsCount) break; + serialization::readString(inputFile, ftpPassword); + if (++settingsRead >= fileSettingsCount) break; + serialization::readString(inputFile, httpUsername); + if (++settingsRead >= fileSettingsCount) break; + serialization::readString(inputFile, httpPassword); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index bb38df68..2cad82ae 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -1,6 +1,7 @@ #pragma once #include #include +#include class CrossPointSettings { private: @@ -75,6 +76,12 @@ class CrossPointSettings { // E-ink refresh frequency (default 15 pages) uint8_t refreshFrequency = REFRESH_15; + // Network credentials for FTP and HTTP servers + std::string ftpUsername = "crosspoint"; + std::string ftpPassword = "reader"; + std::string httpUsername = "crosspoint"; + std::string httpPassword = "reader"; + ~CrossPointSettings() = default; // Get singleton instance diff --git a/src/activities/network/FileTransferActivity.cpp b/src/activities/network/FileTransferActivity.cpp new file mode 100644 index 00000000..142efe05 --- /dev/null +++ b/src/activities/network/FileTransferActivity.cpp @@ -0,0 +1,487 @@ +#include "FileTransferActivity.h" + +#include +#include +#include +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "WifiSelectionActivity.h" +#include "fontIds.h" + +namespace { +// AP Mode configuration +constexpr const char* AP_SSID = "CrossPoint-Reader"; +constexpr const char* AP_PASSWORD = nullptr; // Open network for ease of use +constexpr const char* AP_HOSTNAME = "crosspoint"; +constexpr uint8_t AP_CHANNEL = 1; +constexpr uint8_t AP_MAX_CONNECTIONS = 4; + +// DNS server for captive portal (redirects all DNS queries to our IP) +DNSServer* dnsServer = nullptr; +constexpr uint16_t DNS_PORT = 53; + +void drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std::string& data) { + // Implementation of QR code calculation + // The structure to manage the QR code + QRCode qrcode; + uint8_t qrcodeBytes[qrcode_getBufferSize(4)]; + Serial.printf("[%lu] [FTACT] QR Code (%lu): %s\n", millis(), data.length(), data.c_str()); + + qrcode_initText(&qrcode, qrcodeBytes, 4, ECC_LOW, data.c_str()); + const uint8_t px = 6; // pixels per module + for (uint8_t cy = 0; cy < qrcode.size; cy++) { + for (uint8_t cx = 0; cx < qrcode.size; cx++) { + if (qrcode_getModule(&qrcode, cx, cy)) { + renderer.fillRect(x + px * cx, y + px * cy, px, px, true); + } + } + } +} +} // namespace + +void FileTransferActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void FileTransferActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + Serial.printf("[%lu] [FTACT] [MEM] Free heap at onEnter: %d bytes\n", millis(), ESP.getFreeHeap()); + + renderingMutex = xSemaphoreCreateMutex(); + + // Reset state + state = FileTransferActivityState::PROTOCOL_SELECTION; + selectedProtocol = FileTransferProtocol::HTTP; + networkMode = NetworkMode::JOIN_NETWORK; + isApMode = false; + connectedIP.clear(); + connectedSSID.clear(); + lastHandleClientTime = 0; + updateRequired = true; + + xTaskCreate(&FileTransferActivity::taskTrampoline, "FileTransferTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); + + // Launch protocol selection subactivity + Serial.printf("[%lu] [FTACT] Launching ProtocolSelectionActivity...\n", millis()); + enterNewActivity(new ProtocolSelectionActivity(renderer, mappedInput, + [this](const FileTransferProtocol protocol) { + onProtocolSelected(protocol); + })); +} + +void FileTransferActivity::onExit() { + ActivityWithSubactivity::onExit(); + + Serial.printf("[%lu] [FTACT] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap()); + + state = FileTransferActivityState::SHUTTING_DOWN; + + // Stop the server first (before disconnecting WiFi) + stopServer(); + + // Stop mDNS + MDNS.end(); + + // Stop DNS server if running (AP mode) + if (dnsServer) { + Serial.printf("[%lu] [FTACT] Stopping DNS server...\n", millis()); + dnsServer->stop(); + delete dnsServer; + dnsServer = nullptr; + } + + // CRITICAL: Wait for LWIP stack to flush any pending packets + Serial.printf("[%lu] [FTACT] Waiting 500ms for network stack to flush pending packets...\n", millis()); + delay(500); + + // Disconnect WiFi gracefully + if (isApMode) { + Serial.printf("[%lu] [FTACT] Stopping WiFi AP...\n", millis()); + WiFi.softAPdisconnect(true); + } else { + Serial.printf("[%lu] [FTACT] Disconnecting WiFi (graceful)...\n", millis()); + WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame + } + delay(100); // Allow disconnect frame to be sent + + Serial.printf("[%lu] [FTACT] Setting WiFi mode OFF...\n", millis()); + WiFi.mode(WIFI_OFF); + delay(100); // Allow WiFi hardware to fully power down + + Serial.printf("[%lu] [FTACT] [MEM] Free heap after WiFi disconnect: %d bytes\n", millis(), ESP.getFreeHeap()); + + // Acquire mutex before deleting task + Serial.printf("[%lu] [FTACT] Acquiring rendering mutex before task deletion...\n", millis()); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + + // Delete the display task + Serial.printf("[%lu] [FTACT] Deleting display task...\n", millis()); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + Serial.printf("[%lu] [FTACT] Display task deleted\n", millis()); + } + + // Delete the mutex + Serial.printf("[%lu] [FTACT] Deleting mutex...\n", millis()); + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + Serial.printf("[%lu] [FTACT] Mutex deleted\n", millis()); + + Serial.printf("[%lu] [FTACT] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap()); +} + +void FileTransferActivity::onProtocolSelected(const FileTransferProtocol protocol) { + Serial.printf("[%lu] [FTACT] Protocol selected: %s\n", millis(), protocol == FileTransferProtocol::HTTP ? "HTTP" : "FTP"); + + selectedProtocol = protocol; + + // Exit protocol selection subactivity + exitActivity(); + + // Launch network mode selection + state = FileTransferActivityState::MODE_SELECTION; + Serial.printf("[%lu] [FTACT] Launching NetworkModeSelectionActivity...\n", millis()); + enterNewActivity(new NetworkModeSelectionActivity( + renderer, mappedInput, [this](const NetworkMode mode) { onNetworkModeSelected(mode); }, + [this]() { onGoBack(); } // Cancel goes back to home + )); +} + +void FileTransferActivity::onNetworkModeSelected(const NetworkMode mode) { + Serial.printf("[%lu] [FTACT] Network mode selected: %s\n", millis(), + mode == NetworkMode::JOIN_NETWORK ? "Join Network" : "Create Hotspot"); + + networkMode = mode; + isApMode = (mode == NetworkMode::CREATE_HOTSPOT); + + // Exit mode selection subactivity + exitActivity(); + + if (mode == NetworkMode::JOIN_NETWORK) { + // STA mode - launch WiFi selection + Serial.printf("[%lu] [FTACT] Turning on WiFi (STA mode)...\n", millis()); + WiFi.mode(WIFI_STA); + + state = FileTransferActivityState::WIFI_SELECTION; + Serial.printf("[%lu] [FTACT] Launching WifiSelectionActivity...\n", millis()); + enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, + [this](const bool connected) { onWifiSelectionComplete(connected); })); + } else { + // AP mode - start access point + state = FileTransferActivityState::AP_STARTING; + updateRequired = true; + startAccessPoint(); + } +} + +void FileTransferActivity::onWifiSelectionComplete(const bool connected) { + Serial.printf("[%lu] [FTACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected); + + if (connected) { + // Get connection info before exiting subactivity + connectedIP = static_cast(subActivity.get())->getConnectedIP(); + connectedSSID = WiFi.SSID().c_str(); + isApMode = false; + + exitActivity(); + + // Start mDNS for hostname resolution + if (MDNS.begin(AP_HOSTNAME)) { + Serial.printf("[%lu] [FTACT] mDNS started: %s.local\n", millis(), AP_HOSTNAME); + } + + // Start the server + startServer(); + } else { + // User cancelled - go back to mode selection + exitActivity(); + state = FileTransferActivityState::MODE_SELECTION; + enterNewActivity(new NetworkModeSelectionActivity( + renderer, mappedInput, [this](const NetworkMode mode) { onNetworkModeSelected(mode); }, + [this]() { onGoBack(); })); + } +} + +void FileTransferActivity::startAccessPoint() { + Serial.printf("[%lu] [FTACT] Starting Access Point mode...\n", millis()); + Serial.printf("[%lu] [FTACT] [MEM] Free heap before AP start: %d bytes\n", millis(), ESP.getFreeHeap()); + + // Configure and start the AP + WiFi.mode(WIFI_AP); + delay(100); + + // Start soft AP + bool apStarted; + if (AP_PASSWORD && strlen(AP_PASSWORD) >= 8) { + apStarted = WiFi.softAP(AP_SSID, AP_PASSWORD, AP_CHANNEL, false, AP_MAX_CONNECTIONS); + } else { + // Open network (no password) + apStarted = WiFi.softAP(AP_SSID, nullptr, AP_CHANNEL, false, AP_MAX_CONNECTIONS); + } + + if (!apStarted) { + Serial.printf("[%lu] [FTACT] ERROR: Failed to start Access Point!\n", millis()); + onGoBack(); + return; + } + + delay(100); // Wait for AP to fully initialize + + // Get AP IP address + const IPAddress apIP = WiFi.softAPIP(); + char ipStr[16]; + snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", apIP[0], apIP[1], apIP[2], apIP[3]); + connectedIP = ipStr; + connectedSSID = AP_SSID; + + Serial.printf("[%lu] [FTACT] Access Point started!\n", millis()); + Serial.printf("[%lu] [FTACT] SSID: %s\n", millis(), AP_SSID); + Serial.printf("[%lu] [FTACT] IP: %s\n", millis(), connectedIP.c_str()); + + // Start mDNS for hostname resolution + if (MDNS.begin(AP_HOSTNAME)) { + Serial.printf("[%lu] [FTACT] mDNS started: %s.local\n", millis(), AP_HOSTNAME); + } else { + Serial.printf("[%lu] [FTACT] WARNING: mDNS failed to start\n", millis()); + } + + // Start DNS server for captive portal behavior + // This redirects all DNS queries to our IP, making any domain typed resolve to us + dnsServer = new DNSServer(); + dnsServer->setErrorReplyCode(DNSReplyCode::NoError); + dnsServer->start(DNS_PORT, "*", apIP); + Serial.printf("[%lu] [FTACT] DNS server started for captive portal\n", millis()); + + Serial.printf("[%lu] [FTACT] [MEM] Free heap after AP start: %d bytes\n", millis(), ESP.getFreeHeap()); + + // Start the server + startServer(); +} + +void FileTransferActivity::startServer() { + Serial.printf("[%lu] [FTACT] Starting %s server...\n", millis(), + selectedProtocol == FileTransferProtocol::HTTP ? "HTTP" : "FTP"); + + if (selectedProtocol == FileTransferProtocol::HTTP) { + // Create and start HTTP server + webServer.reset(new CrossPointWebServer()); + webServer->begin(); + + if (webServer->isRunning()) { + state = FileTransferActivityState::SERVER_RUNNING; + Serial.printf("[%lu] [FTACT] HTTP server started successfully\n", millis()); + } else { + Serial.printf("[%lu] [FTACT] ERROR: Failed to start HTTP server!\n", millis()); + webServer.reset(); + onGoBack(); + return; + } + } else { + // Create and start FTP server + ftpServer.reset(new CrossPointFtpServer()); + if (ftpServer->begin()) { + state = FileTransferActivityState::SERVER_RUNNING; + Serial.printf("[%lu] [FTACT] FTP server started successfully\n", millis()); + } else { + Serial.printf("[%lu] [FTACT] ERROR: Failed to start FTP server!\n", millis()); + ftpServer.reset(); + onGoBack(); + return; + } + } + + // Force an immediate render since we're transitioning from a subactivity + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + Serial.printf("[%lu] [FTACT] Rendered File Transfer screen\n", millis()); +} + +void FileTransferActivity::stopServer() { + if (webServer && webServer->isRunning()) { + Serial.printf("[%lu] [FTACT] Stopping HTTP server...\n", millis()); + webServer->stop(); + Serial.printf("[%lu] [FTACT] HTTP server stopped\n", millis()); + } + webServer.reset(); + + if (ftpServer && ftpServer->running()) { + Serial.printf("[%lu] [FTACT] Stopping FTP server...\n", millis()); + ftpServer->stop(); + Serial.printf("[%lu] [FTACT] FTP server stopped\n", millis()); + } + ftpServer.reset(); +} + +void FileTransferActivity::loop() { + if (subActivity) { + // Forward loop to subactivity + subActivity->loop(); + return; + } + + // Handle different states + if (state == FileTransferActivityState::SERVER_RUNNING) { + // Handle DNS requests for captive portal (AP mode only) + if (isApMode && dnsServer) { + dnsServer->processNextRequest(); + } + + // Handle server requests + if (selectedProtocol == FileTransferProtocol::HTTP && webServer && webServer->isRunning()) { + const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; + + // Log if there's a significant gap between handleClient calls (>100ms) + if (lastHandleClientTime > 0 && timeSinceLastHandleClient > 100) { + Serial.printf("[%lu] [FTACT] WARNING: %lu ms gap since last handleClient\n", millis(), + timeSinceLastHandleClient); + } + + // Call handleClient multiple times to process pending requests faster + constexpr int HANDLE_CLIENT_ITERATIONS = 10; + for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) { + webServer->handleClient(); + } + lastHandleClientTime = millis(); + } else if (selectedProtocol == FileTransferProtocol::FTP && ftpServer && ftpServer->running()) { + ftpServer->handleClient(); + } + + // Handle exit on Back button + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + onGoBack(); + return; + } + } +} + +void FileTransferActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void FileTransferActivity::render() const { + // Only render our own UI when server is running + // Subactivities handle their own rendering + if (state == FileTransferActivityState::SERVER_RUNNING) { + renderer.clearScreen(); + renderServerRunning(); + renderer.displayBuffer(); + } else if (state == FileTransferActivityState::AP_STARTING) { + renderer.clearScreen(); + const auto pageHeight = renderer.getScreenHeight(); + renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Starting Hotspot...", true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + } +} + +void FileTransferActivity::renderServerRunning() const { + // Use consistent line spacing + constexpr int LINE_SPACING = 28; // Space between lines + + const char* protocolName = selectedProtocol == FileTransferProtocol::HTTP ? "HTTP" : "FTP"; + const std::string title = std::string("File Transfer (") + protocolName + ")"; + renderer.drawCenteredText(UI_12_FONT_ID, 15, title.c_str(), true, EpdFontFamily::BOLD); + + if (isApMode) { + // AP mode display - center the content block + int startY = 55; + + renderer.drawCenteredText(UI_10_FONT_ID, startY, "Hotspot Mode", true, EpdFontFamily::BOLD); + + std::string ssidInfo = "Network: " + connectedSSID; + renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING, ssidInfo.c_str()); + + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 2, "Connect your device to this WiFi network"); + + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, + "or scan QR code with your phone to connect to WiFi:"); + // Show QR code for WiFi + const std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;"; + drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 4, wifiConfig); + + startY += 6 * 29 + 3 * LINE_SPACING; + + // Show URL based on protocol + std::string serverUrl; + if (selectedProtocol == FileTransferProtocol::HTTP) { + serverUrl = std::string("http://") + AP_HOSTNAME + ".local/"; + renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 3, serverUrl.c_str(), true, EpdFontFamily::BOLD); + + std::string ipUrl = "or http://" + connectedIP + "/"; + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, ipUrl.c_str()); + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "Open this URL in your browser"); + } else { + // FTP URL with credentials + serverUrl = std::string("ftp://") + SETTINGS.ftpUsername + ":" + SETTINGS.ftpPassword + "@" + connectedIP + "/"; + renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 3, serverUrl.c_str(), true, EpdFontFamily::BOLD); + + std::string ftpInfo = "User: " + SETTINGS.ftpUsername + " | Pass: " + SETTINGS.ftpPassword; + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, ftpInfo.c_str()); + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "Use FTP client or scan QR code:"); + } + + // Show QR code for server URL + drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 6, serverUrl); + } else { + // STA mode display + const int startY = 65; + + 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()); + + std::string ipInfo = "IP Address: " + connectedIP; + renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING, ipInfo.c_str()); + + // Show server URL based on protocol + std::string serverUrl; + if (selectedProtocol == FileTransferProtocol::HTTP) { + serverUrl = "http://" + connectedIP + "/"; + renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 2, serverUrl.c_str(), true, EpdFontFamily::BOLD); + + std::string hostnameUrl = std::string("or http://") + AP_HOSTNAME + ".local/"; + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str()); + + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser"); + } else { + // FTP URL with credentials + serverUrl = std::string("ftp://") + SETTINGS.ftpUsername + ":" + SETTINGS.ftpPassword + "@" + connectedIP + "/"; + renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 2, serverUrl.c_str(), true, EpdFontFamily::BOLD); + + std::string ftpInfo = "User: " + SETTINGS.ftpUsername + " | Pass: " + SETTINGS.ftpPassword; + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, ftpInfo.c_str()); + + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Use FTP client or scan QR code:"); + } + + // Show QR code for server URL + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "or scan QR code with your phone:"); + drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 6, serverUrl); + } + + const auto labels = mappedInput.mapLabels("« Exit", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} diff --git a/src/activities/network/FileTransferActivity.h b/src/activities/network/FileTransferActivity.h new file mode 100644 index 00000000..77004ff4 --- /dev/null +++ b/src/activities/network/FileTransferActivity.h @@ -0,0 +1,78 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "NetworkModeSelectionActivity.h" +#include "ProtocolSelectionActivity.h" +#include "activities/ActivityWithSubactivity.h" +#include "network/CrossPointFtpServer.h" +#include "network/CrossPointWebServer.h" + +// File transfer activity states +enum class FileTransferActivityState { + PROTOCOL_SELECTION, // Choosing between HTTP and FTP + MODE_SELECTION, // Choosing between Join Network and Create Hotspot + WIFI_SELECTION, // WiFi selection subactivity is active (for Join Network mode) + AP_STARTING, // Starting Access Point mode + SERVER_RUNNING, // Server is running and handling requests + SHUTTING_DOWN // Shutting down server and WiFi +}; + +/** + * FileTransferActivity is the entry point for file transfer functionality. + * It allows users to choose between HTTP and FTP protocols for file transfer. + */ +class FileTransferActivity final : public ActivityWithSubactivity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + FileTransferActivityState state = FileTransferActivityState::PROTOCOL_SELECTION; + const std::function onGoBack; + + // Selected protocol + FileTransferProtocol selectedProtocol = FileTransferProtocol::HTTP; + + // Network mode + NetworkMode networkMode = NetworkMode::JOIN_NETWORK; + bool isApMode = false; + + // Servers - owned by this activity + std::unique_ptr webServer; + std::unique_ptr ftpServer; + + // Server status + std::string connectedIP; + std::string connectedSSID; // For STA mode: network name, For AP mode: AP name + + // Performance monitoring + unsigned long lastHandleClientTime = 0; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + void renderServerRunning() const; + + void onProtocolSelected(FileTransferProtocol protocol); + void onNetworkModeSelected(NetworkMode mode); + void onWifiSelectionComplete(bool connected); + void startAccessPoint(); + void startServer(); + void stopServer(); + + public: + explicit FileTransferActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onGoBack) + : ActivityWithSubactivity("FileTransfer", renderer, mappedInput), onGoBack(onGoBack) {} + void onEnter() override; + void onExit() override; + void loop() override; + bool skipLoopDelay() override { return (webServer && webServer->isRunning()) || (ftpServer && ftpServer->running()); } + bool preventAutoSleep() override { + return (webServer && webServer->isRunning()) || (ftpServer && ftpServer->running()); + } +}; diff --git a/src/activities/network/ProtocolSelectionActivity.cpp b/src/activities/network/ProtocolSelectionActivity.cpp new file mode 100644 index 00000000..d4dab5a6 --- /dev/null +++ b/src/activities/network/ProtocolSelectionActivity.cpp @@ -0,0 +1,123 @@ +#include "ProtocolSelectionActivity.h" + +#include + +#include "MappedInputManager.h" +#include "fontIds.h" + +namespace { +constexpr int MENU_ITEM_COUNT = 2; +const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"HTTP Server", "FTP Server"}; +const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = {"Web-based file transfer via browser", + "FTP protocol for file transfer clients"}; +} // namespace + +void ProtocolSelectionActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void ProtocolSelectionActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + // Reset selection + selectedIndex = 0; + + // Trigger first update + updateRequired = true; + + xTaskCreate(&ProtocolSelectionActivity::taskTrampoline, "ProtocolSelTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void ProtocolSelectionActivity::onExit() { + Activity::onExit(); + + // Wait until not rendering to delete task + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void ProtocolSelectionActivity::loop() { + // Handle confirm button - select current option + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + const FileTransferProtocol protocol = (selectedIndex == 0) ? FileTransferProtocol::HTTP : FileTransferProtocol::FTP; + onProtocolSelected(protocol); + return; + } + + // Handle navigation + const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) || + mappedInput.wasPressed(MappedInputManager::Button::Left); + const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) || + mappedInput.wasPressed(MappedInputManager::Button::Right); + + if (prevPressed) { + selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT; + updateRequired = true; + } else if (nextPressed) { + selectedIndex = (selectedIndex + 1) % MENU_ITEM_COUNT; + updateRequired = true; + } +} + +void ProtocolSelectionActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void ProtocolSelectionActivity::render() const { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Draw header + renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer Protocol", true, EpdFontFamily::BOLD); + + // Draw subtitle + renderer.drawCenteredText(UI_10_FONT_ID, 50, "Choose a protocol"); + + // Draw menu items centered on screen + constexpr int itemHeight = 50; // Height for each menu item (including description) + const int startY = (pageHeight - (MENU_ITEM_COUNT * itemHeight)) / 2 + 10; + + for (int i = 0; i < MENU_ITEM_COUNT; i++) { + const int itemY = startY + i * itemHeight; + const bool isSelected = (i == selectedIndex); + + // Draw selection highlight (black fill) for selected item + if (isSelected) { + renderer.fillRect(20, itemY - 2, pageWidth - 40, itemHeight - 6); + } + + // Draw text: black=false (white text) when selected (on black background) + // black=true (black text) when not selected (on white background) + renderer.drawText(UI_10_FONT_ID, 30, itemY, MENU_ITEMS[i], /*black=*/!isSelected); + renderer.drawText(SMALL_FONT_ID, 30, itemY + 22, MENU_DESCRIPTIONS[i], /*black=*/!isSelected); + } + + // Draw help text at bottom + const auto labels = mappedInput.mapLabels("", "Select", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/network/ProtocolSelectionActivity.h b/src/activities/network/ProtocolSelectionActivity.h new file mode 100644 index 00000000..4e526dd9 --- /dev/null +++ b/src/activities/network/ProtocolSelectionActivity.h @@ -0,0 +1,30 @@ +#pragma once +#include +#include +#include + +#include + +#include "../Activity.h" + +enum class FileTransferProtocol { HTTP, FTP }; + +class ProtocolSelectionActivity final : public Activity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + int selectedIndex = 0; + bool updateRequired = false; + const std::function onProtocolSelected; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + + public: + explicit ProtocolSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onProtocolSelected) + : Activity("ProtocolSelection", renderer, mappedInput), onProtocolSelected(onProtocolSelected) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/main.cpp b/src/main.cpp index e81448bd..86a2d4ae 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,7 +14,7 @@ #include "activities/boot_sleep/BootActivity.h" #include "activities/boot_sleep/SleepActivity.h" #include "activities/home/HomeActivity.h" -#include "activities/network/CrossPointWebServerActivity.h" +#include "activities/network/FileTransferActivity.h" #include "activities/reader/ReaderActivity.h" #include "activities/settings/SettingsActivity.h" #include "activities/util/FullScreenMessageActivity.h" @@ -214,7 +214,7 @@ void onContinueReading() { onGoToReader(APP_STATE.openEpubPath); } void onGoToFileTransfer() { exitActivity(); - enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome)); + enterNewActivity(new FileTransferActivity(renderer, mappedInputManager, onGoHome)); } void onGoToSettings() { diff --git a/src/network/CrossPointFtpServer.cpp b/src/network/CrossPointFtpServer.cpp new file mode 100644 index 00000000..31b96f6b --- /dev/null +++ b/src/network/CrossPointFtpServer.cpp @@ -0,0 +1,65 @@ +#include "CrossPointFtpServer.h" + +#include +#include +#include +#include + +#include "../CrossPointSettings.h" + +CrossPointFtpServer::~CrossPointFtpServer() { + stop(); +} + +bool CrossPointFtpServer::begin() { + if (isRunning) { + Serial.printf("[%lu] [FTP] Server already running\n", millis()); + return true; + } + + // Check WiFi connection + if (WiFi.status() != WL_CONNECTED && WiFi.getMode() != WIFI_AP) { + Serial.printf("[%lu] [FTP] WiFi not connected\n", millis()); + return false; + } + + // Disable WiFi sleep for better responsiveness + esp_wifi_set_ps(WIFI_PS_NONE); + + Serial.printf("[%lu] [FTP] Free heap before starting: %lu bytes\n", millis(), ESP.getFreeHeap()); + + // Create and start FTP server + ftpServer = new FtpServer(); + + // Start FTP server with credentials from settings + // The library automatically uses the global SdFat2 filesystem + ftpServer->begin(SETTINGS.ftpUsername.c_str(), SETTINGS.ftpPassword.c_str()); + + isRunning = true; + + Serial.printf("[%lu] [FTP] Server started on port 21\n", millis()); + Serial.printf("[%lu] [FTP] Username: %s\n", millis(), SETTINGS.ftpUsername.c_str()); + Serial.printf("[%lu] [FTP] Free heap after starting: %lu bytes\n", millis(), ESP.getFreeHeap()); + + return true; +} + +void CrossPointFtpServer::stop() { + if (!isRunning) { + return; + } + + if (ftpServer) { + delete ftpServer; + ftpServer = nullptr; + } + + isRunning = false; + Serial.printf("[%lu] [FTP] Server stopped\n", millis()); +} + +void CrossPointFtpServer::handleClient() { + if (isRunning && ftpServer) { + ftpServer->handleFTP(); + } +} diff --git a/src/network/CrossPointFtpServer.h b/src/network/CrossPointFtpServer.h new file mode 100644 index 00000000..fde1c778 --- /dev/null +++ b/src/network/CrossPointFtpServer.h @@ -0,0 +1,28 @@ +#pragma once + +// Configure SimpleFTPServer to use SdFat2 instead of SD library +#define DEFAULT_STORAGE_TYPE_ESP32 2 // STORAGE_SDFAT2 + +#include + +class CrossPointFtpServer { + private: + FtpServer* ftpServer = nullptr; + bool isRunning = false; + + public: + CrossPointFtpServer() = default; + ~CrossPointFtpServer(); + + // Initialize and start the FTP server + bool begin(); + + // Stop the FTP server + void stop(); + + // Handle FTP client requests (call this regularly in loop) + void handleClient(); + + // Check if server is running + bool running() const { return isRunning; } +};