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 cd8b56f7..06692c1d 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -14,7 +14,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 = 17; +constexpr uint8_t SETTINGS_COUNT = 22; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -42,10 +42,15 @@ 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); + serialization::writePod(outputFile, hotspotSchedulerEnabled); + serialization::writePod(outputFile, hotspotSchedulerHour); + serialization::writePod(outputFile, hotspotSchedulerMinute); + serialization::writePod(outputFile, hotspotSchedulerShutdownTime); serialization::writePod(outputFile, screenMargin); - serialization::writePod(outputFile, sleepScreenCoverMode); - serialization::writeString(outputFile, std::string(opdsServerUrl)); - serialization::writePod(outputFile, textAntiAliasing); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -98,9 +103,23 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, refreshFrequency); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, screenMargin); + serialization::readString(inputFile, ftpUsername); if (++settingsRead >= fileSettingsCount) break; - serialization::readPod(inputFile, sleepScreenCoverMode); + serialization::readString(inputFile, ftpPassword); + if (++settingsRead >= fileSettingsCount) break; + serialization::readString(inputFile, httpUsername); + if (++settingsRead >= fileSettingsCount) break; + serialization::readString(inputFile, httpPassword); + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, hotspotSchedulerEnabled); + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, hotspotSchedulerHour); + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, hotspotSchedulerMinute); + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, hotspotSchedulerShutdownTime); + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, screenMargin); if (++settingsRead >= fileSettingsCount) break; { std::string urlStr; @@ -226,3 +245,21 @@ int CrossPointSettings::getReaderFontId() const { } } } +unsigned int CrossPointSettings::getHotspotShutdownMinutes() const { + switch (hotspotSchedulerShutdownTime) { + case SHUTDOWN_5_MIN: + return 5; + case SHUTDOWN_10_MIN: + return 10; + case SHUTDOWN_15_MIN: + return 15; + case SHUTDOWN_30_MIN: + return 30; + case SHUTDOWN_60_MIN: + return 60; + case SHUTDOWN_120_MIN: + return 120; + default: + return 30; + } +} \ No newline at end of file diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 3a2a3503..824a4f6c 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -1,6 +1,7 @@ #pragma once #include #include +#include class CrossPointSettings { private: @@ -52,6 +53,16 @@ class CrossPointSettings { // E-ink refresh frequency (pages between full refreshes) enum REFRESH_FREQUENCY { REFRESH_1 = 0, REFRESH_5 = 1, REFRESH_10 = 2, REFRESH_15 = 3, REFRESH_30 = 4 }; + // Hotspot scheduler settings + enum HOTSPOT_SHUTDOWN_TIME { + SHUTDOWN_5_MIN = 0, + SHUTDOWN_10_MIN = 1, + SHUTDOWN_15_MIN = 2, + SHUTDOWN_30_MIN = 3, + SHUTDOWN_60_MIN = 4, + SHUTDOWN_120_MIN = 5 + }; + // Sleep screen settings uint8_t sleepScreen = DARK; // Sleep screen cover mode settings @@ -78,10 +89,20 @@ class CrossPointSettings { uint8_t sleepTimeout = SLEEP_10_MIN; // E-ink refresh frequency (default 15 pages) uint8_t refreshFrequency = REFRESH_15; - // Reader screen margin settings - uint8_t screenMargin = 5; - // OPDS browser settings - char opdsServerUrl[128] = ""; + // Screen margin setting (in pixels, default 0) + uint8_t screenMargin = 0; + + // Network credentials for FTP and HTTP servers + std::string ftpUsername = "crosspoint"; + std::string ftpPassword = "reader"; + std::string httpUsername = "crosspoint"; + std::string httpPassword = "reader"; + + // Hotspot scheduler settings + uint8_t hotspotSchedulerEnabled = 0; // 0 = disabled, 1 = enabled + uint8_t hotspotSchedulerHour = 12; // Hour (0-23) + uint8_t hotspotSchedulerMinute = 0; // Minute (0-59) + uint8_t hotspotSchedulerShutdownTime = SHUTDOWN_30_MIN; // Default 30 minutes ~CrossPointSettings() = default; @@ -90,6 +111,7 @@ class CrossPointSettings { uint16_t getPowerButtonDuration() const { return shortPwrBtn ? 10 : 400; } int getReaderFontId() const; + unsigned int getHotspotShutdownMinutes() const; bool saveToFile() const; bool loadFromFile(); diff --git a/src/activities/network/FileTransferActivity.cpp b/src/activities/network/FileTransferActivity.cpp new file mode 100644 index 00000000..07fe7a3c --- /dev/null +++ b/src/activities/network/FileTransferActivity.cpp @@ -0,0 +1,486 @@ +#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/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 702db172..02a695bf 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -16,31 +16,59 @@ namespace { constexpr int settingsCount = 18; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE - SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), - SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}), - SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}), - SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing), - SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing), - SettingInfo::Toggle("Short Power Button Click", &CrossPointSettings::shortPwrBtn), - SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation, - {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}), - SettingInfo::Enum("Front Button Layout", &CrossPointSettings::frontButtonLayout, - {"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght"}), - SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout, - {"Prev, Next", "Next, Prev"}), - SettingInfo::Enum("Reader Font Family", &CrossPointSettings::fontFamily, - {"Bookerly", "Noto Sans", "Open Dyslexic"}), - SettingInfo::Enum("Reader Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}), - SettingInfo::Enum("Reader Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}), - SettingInfo::Value("Reader Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}), - SettingInfo::Enum("Reader Paragraph Alignment", &CrossPointSettings::paragraphAlignment, - {"Justify", "Left", "Center", "Right"}), - SettingInfo::Enum("Time to Sleep", &CrossPointSettings::sleepTimeout, - {"1 min", "5 min", "10 min", "15 min", "30 min"}), - SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, - {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), - SettingInfo::Action("Calibre Settings"), - SettingInfo::Action("Check for updates")}; + {"Sleep Screen", + SettingType::ENUM, + &CrossPointSettings::sleepScreen, + nullptr, + {"Dark", "Light", "Custom", "Cover"}}, + {"Status Bar", SettingType::ENUM, &CrossPointSettings::statusBar, nullptr, {"None", "No Progress", "Full"}}, + {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, nullptr, {}}, + {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, nullptr, {}}, + {"Reading Orientation", + SettingType::ENUM, + &CrossPointSettings::orientation, + nullptr, + {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}}, + {"Front Button Layout", + SettingType::ENUM, + &CrossPointSettings::frontButtonLayout, + nullptr, + {"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght"}}, + {"Side Button Layout (reader)", + SettingType::ENUM, + &CrossPointSettings::sideButtonLayout, + nullptr, + {"Prev, Next", "Next, Prev"}}, + {"Reader Font Family", + SettingType::ENUM, + &CrossPointSettings::fontFamily, + nullptr, + {"Bookerly", "Noto Sans", "Open Dyslexic"}}, + {"Reader Font Size", + SettingType::ENUM, + &CrossPointSettings::fontSize, + nullptr, + {"Small", "Medium", "Large", "X Large"}}, + {"Reader Line Spacing", SettingType::ENUM, &CrossPointSettings::lineSpacing, nullptr, {"Tight", "Normal", "Wide"}}, + {"Reader Paragraph Alignment", + SettingType::ENUM, + &CrossPointSettings::paragraphAlignment, + nullptr, + {"Justify", "Left", "Center", "Right"}}, + {"Time to Sleep", + SettingType::ENUM, + &CrossPointSettings::sleepTimeout, + nullptr, + {"1 min", "5 min", "10 min", "15 min", "30 min"}}, + {"Refresh Frequency", + SettingType::ENUM, + &CrossPointSettings::refreshFrequency, + nullptr, + {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}}, + {"FTP Username", SettingType::TEXT, nullptr, &CrossPointSettings::ftpUsername, {}}, + {"FTP Password", SettingType::TEXT, nullptr, &CrossPointSettings::ftpPassword, {}}, + {"Check for updates", SettingType::ACTION, nullptr, nullptr, {}}, +}; } // namespace void SettingsActivity::taskTrampoline(void* param) { @@ -127,15 +155,10 @@ void SettingsActivity::toggleCurrentSetting() { } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { const uint8_t currentValue = SETTINGS.*(setting.valuePtr); SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast(setting.enumValues.size()); - } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { - // Decreasing would also be nice for large ranges I think but oh well can't have everything - const int8_t currentValue = SETTINGS.*(setting.valuePtr); - // Wrap to minValue if exceeding setting value boundary - if (currentValue + setting.valueRange.step > setting.valueRange.max) { - SETTINGS.*(setting.valuePtr) = setting.valueRange.min; - } else { - SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step; - } + } else if (setting.type == SettingType::TEXT && setting.stringValuePtr != nullptr) { + // For now, TEXT settings are display-only in this UI + // In a future version, this could launch a text input dialog + return; } else if (setting.type == SettingType::ACTION) { if (strcmp(setting.name, "Calibre Settings") == 0) { xSemaphoreTake(renderingMutex, portMAX_DELAY); @@ -202,8 +225,8 @@ void SettingsActivity::render() const { } else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) { const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); valueText = settingsList[i].enumValues[value]; - } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) { - valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); + } else if (settingsList[i].type == SettingType::TEXT && settingsList[i].stringValuePtr != nullptr) { + valueText = SETTINGS.*(settingsList[i].stringValuePtr); } const auto width = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), i != selectedSettingIndex); diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 157689e3..1afafd4f 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -11,13 +11,14 @@ class CrossPointSettings; -enum class SettingType { TOGGLE, ENUM, ACTION, VALUE }; +enum class SettingType { TOGGLE, ENUM, ACTION, TEXT, VALUE }; // Structure to hold setting information struct SettingInfo { - const char* name; // Display name of the setting - SettingType type; // Type of setting - uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE/ENUM/VALUE) + const char* name; // Display name of the setting + SettingType type; // Type of setting + uint8_t CrossPointSettings::* valuePtr; // Pointer to member in CrossPointSettings (for TOGGLE/ENUM) + std::string CrossPointSettings::* stringValuePtr; // Pointer to string member (for TEXT) std::vector enumValues; struct ValueRange { @@ -30,17 +31,17 @@ struct SettingInfo { // Static constructors static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) { - return {name, SettingType::TOGGLE, ptr}; + return {name, SettingType::TOGGLE, ptr, nullptr, {}, {0, 0, 0}}; } static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector values) { - return {name, SettingType::ENUM, ptr, std::move(values)}; + return {name, SettingType::ENUM, ptr, nullptr, std::move(values), {0, 0, 0}}; } - static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; } + static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr, nullptr, {}, {0, 0, 0}}; } static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) { - return {name, SettingType::VALUE, ptr, {}, valueRange}; + return {name, SettingType::VALUE, ptr, nullptr, {}, valueRange}; } }; diff --git a/src/main.cpp b/src/main.cpp index 5261df3d..c802af16 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -17,7 +17,7 @@ #include "activities/boot_sleep/SleepActivity.h" #include "activities/browser/OpdsBookBrowserActivity.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" @@ -217,7 +217,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..62d9c02a --- /dev/null +++ b/src/network/CrossPointFtpServer.cpp @@ -0,0 +1,63 @@ +#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; } +};