diff --git a/platformio.ini b/platformio.ini index fad8c08c..0d1468a9 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,3 +1,13 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + [platformio] crosspoint_version = 0.11.2 default_envs = default @@ -11,49 +21,41 @@ upload_speed = 921600 check_tool = cppcheck check_flags = --enable=all --suppress=missingIncludeSystem --suppress=unusedFunction --suppress=unmatchedSuppression --suppress=*:*/.pio/* --inline-suppr check_skip_packages = yes - board_upload.flash_size = 16MB board_upload.maximum_size = 16777216 board_upload.offset_address = 0x10000 - -build_flags = - -DARDUINO_USB_MODE=1 - -DARDUINO_USB_CDC_ON_BOOT=1 - -DMINIZ_NO_ZLIB_COMPATIBLE_NAMES=1 - -DEINK_DISPLAY_SINGLE_BUFFER_MODE=1 - -DDISABLE_FS_H_WARNING=1 -# https://libexpat.github.io/doc/api/latest/#XML_GE - -DXML_GE=0 - -DXML_CONTEXT_BYTES=1024 - -std=c++2a -# Enable UTF-8 long file names in SdFat - -DUSE_UTF8_LONG_NAMES=1 - -; Board configuration +build_flags = + -DARDUINO_USB_MODE=1 + -DARDUINO_USB_CDC_ON_BOOT=1 + -DMINIZ_NO_ZLIB_COMPATIBLE_NAMES=1 + -DEINK_DISPLAY_SINGLE_BUFFER_MODE=1 + -DDISABLE_FS_H_WARNING=1 + -DXML_GE=0 + -DXML_CONTEXT_BYTES=1024 + -std=c++2a + -DUSE_UTF8_LONG_NAMES=1 board_build.flash_mode = dio board_build.flash_size = 16MB board_build.partitions = partitions.csv - -extra_scripts = - pre:scripts/build_html.py - -; Libraries -lib_deps = - BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor - InputManager=symlink://open-x4-sdk/libs/hardware/InputManager - EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay - SDCardManager=symlink://open-x4-sdk/libs/hardware/SDCardManager - ArduinoJson @ 7.4.2 - QRCode @ 0.0.1 +extra_scripts = + pre:scripts/build_html.py +lib_deps = + BatteryMonitor=symlink://open-x4-sdk/libs/hardware/BatteryMonitor + InputManager=symlink://open-x4-sdk/libs/hardware/InputManager + EInkDisplay=symlink://open-x4-sdk/libs/display/EInkDisplay + 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 build_flags = - ${base.build_flags} - -DCROSSPOINT_VERSION=\"${platformio.crosspoint_version}-dev\" + ${base.build_flags} + -DCROSSPOINT_VERSION=\"${platformio.crosspoint_version}-dev\" [env:gh_release] extends = base build_flags = - ${base.build_flags} - -DCROSSPOINT_VERSION=\"${platformio.crosspoint_version}\" + ${base.build_flags} + -DCROSSPOINT_VERSION=\"${platformio.crosspoint_version}\" diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 16420f3f..02415eff 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 = 16; +constexpr uint8_t SETTINGS_COUNT = 22; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -43,6 +43,12 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, refreshInterval); serialization::writePod(outputFile, defaultFolder); serialization::writeString(outputFile, customDefaultFolder); + serialization::writePod(outputFile, scheduleEnabled); + serialization::writePod(outputFile, scheduleFrequency); + serialization::writePod(outputFile, scheduleProtocol); + serialization::writePod(outputFile, scheduleNetworkMode); + serialization::writePod(outputFile, scheduleHour); + serialization::writePod(outputFile, scheduleAutoShutdown); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -101,6 +107,18 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readString(inputFile, customDefaultFolder); if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, scheduleEnabled); + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, scheduleFrequency); + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, scheduleProtocol); + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, scheduleNetworkMode); + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, scheduleHour); + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, scheduleAutoShutdown); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 4e9def2f..1b910eae 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -77,6 +77,14 @@ class CrossPointSettings { // Default folder for file browser (enum index: 0=Root, 1=Custom, 2=Last Used) uint8_t defaultFolder = FOLDER_LAST_USED; // Default to last used (current behavior) + // Schedule settings for auto-starting file transfer server + uint8_t scheduleEnabled = 0; // 0=disabled, 1=enabled + uint8_t scheduleFrequency = 0; // 0=1hr, 1=2hr, 2=3hr, 3=6hr, 4=12hr, 5=24hr, 6=Scheduled time + uint8_t scheduleProtocol = 0; // 0=HTTP, 1=FTP + uint8_t scheduleNetworkMode = 0; // 0=Join Network, 1=Create Hotspot + uint8_t scheduleHour = 0; // 0-23: Hour of day for scheduled start (when scheduleFrequency=6) + uint8_t scheduleAutoShutdown = 2; // 0=5min, 1=10min, 2=20min, 3=30min, 4=60min, 5=120min + // Custom default folder path (used when defaultFolder == FOLDER_CUSTOM) std::string customDefaultFolder = "/books"; @@ -115,6 +123,33 @@ class CrossPointSettings { return "/"; // Fallback } + unsigned long getScheduleIntervalMs() const { + // Map enum index to milliseconds: 0=1hr, 1=2hr, 2=3hr, 3=6hr, 4=12hr, 5=24hr, 6=Scheduled + constexpr unsigned long intervals[] = { + 1UL * 60UL * 60UL * 1000UL, // 0: 1 hour + 2UL * 60UL * 60UL * 1000UL, // 1: 2 hours + 3UL * 60UL * 60UL * 1000UL, // 2: 3 hours + 6UL * 60UL * 60UL * 1000UL, // 3: 6 hours + 12UL * 60UL * 60UL * 1000UL, // 4: 12 hours + 24UL * 60UL * 60UL * 1000UL, // 5: 24 hours + 0UL // 6: Scheduled (use time-based check) + }; + return (scheduleFrequency < 7) ? intervals[scheduleFrequency] : intervals[0]; + } + + unsigned long getAutoShutdownMs() const { + // Map enum index to milliseconds: 0=5min, 1=10min, 2=20min, 3=30min, 4=60min, 5=120min + constexpr unsigned long durations[] = { + 5UL * 60UL * 1000UL, // 0: 5 minutes + 10UL * 60UL * 1000UL, // 1: 10 minutes + 20UL * 60UL * 1000UL, // 2: 20 minutes (default) + 30UL * 60UL * 1000UL, // 3: 30 minutes + 60UL * 60UL * 1000UL, // 4: 60 minutes + 120UL * 60UL * 1000UL // 5: 120 minutes + }; + return (scheduleAutoShutdown < 6) ? durations[scheduleAutoShutdown] : durations[2]; + } + bool saveToFile() const; bool loadFromFile(); diff --git a/src/CrossPointState.cpp b/src/CrossPointState.cpp index 786c3b2e..78434e7c 100644 --- a/src/CrossPointState.cpp +++ b/src/CrossPointState.cpp @@ -5,7 +5,7 @@ #include namespace { -constexpr uint8_t STATE_FILE_VERSION = 2; +constexpr uint8_t STATE_FILE_VERSION = 3; constexpr char STATE_FILE[] = "/.crosspoint/state.bin"; } // namespace @@ -20,6 +20,7 @@ bool CrossPointState::saveToFile() const { serialization::writePod(outputFile, STATE_FILE_VERSION); serialization::writeString(outputFile, openEpubPath); serialization::writeString(outputFile, lastBrowsedFolder); + serialization::writePod(outputFile, lastScheduledServerTime); outputFile.close(); return true; } @@ -35,11 +36,17 @@ bool CrossPointState::loadFromFile() { if (version == 1) { // Version 1: only had openEpubPath serialization::readString(inputFile, openEpubPath); - lastBrowsedFolder = "/"; // Default for old version - } else if (version == STATE_FILE_VERSION) { + lastScheduledServerTime = 0; + } else if (version == 2) { // Version 2: has openEpubPath and lastBrowsedFolder serialization::readString(inputFile, openEpubPath); serialization::readString(inputFile, lastBrowsedFolder); + lastScheduledServerTime = 0; + } else if (version == STATE_FILE_VERSION) { + // Version 3: has openEpubPath, lastBrowsedFolder, and lastScheduledServerTime + serialization::readString(inputFile, openEpubPath); + serialization::readString(inputFile, lastBrowsedFolder); + serialization::readPod(inputFile, lastScheduledServerTime); } else { Serial.printf("[%lu] [CPS] Deserialization failed: Unknown version %u\n", millis(), version); inputFile.close(); diff --git a/src/CrossPointState.h b/src/CrossPointState.h index 5e1c9686..553f4217 100644 --- a/src/CrossPointState.h +++ b/src/CrossPointState.h @@ -9,6 +9,7 @@ class CrossPointState { public: std::string openEpubPath; std::string lastBrowsedFolder = "/"; + unsigned long lastScheduledServerTime = 0; // Timestamp when scheduled server was last started ~CrossPointState() = default; // Get singleton instance diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h deleted file mode 100644 index deb7cea7..00000000 --- a/src/activities/network/CrossPointWebServerActivity.h +++ /dev/null @@ -1,73 +0,0 @@ -#pragma once -#include -#include -#include - -#include -#include -#include - -#include "NetworkModeSelectionActivity.h" -#include "activities/ActivityWithSubactivity.h" -#include "network/CrossPointWebServer.h" - -// Web server activity states -enum class WebServerActivityState { - 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, // Web server is running and handling requests - SHUTTING_DOWN // Shutting down server and WiFi -}; - -/** - * CrossPointWebServerActivity is the entry point for file transfer functionality. - * It: - * - First presents a choice between "Join a Network" (STA) and "Create Hotspot" (AP) - * - For STA mode: Launches WifiSelectionActivity to connect to an existing network - * - For AP mode: Creates an Access Point that clients can connect to - * - Starts the CrossPointWebServer when connected - * - Handles client requests in its loop() function - * - Cleans up the server and shuts down WiFi on exit - */ -class CrossPointWebServerActivity final : public ActivityWithSubactivity { - TaskHandle_t displayTaskHandle = nullptr; - SemaphoreHandle_t renderingMutex = nullptr; - bool updateRequired = false; - WebServerActivityState state = WebServerActivityState::MODE_SELECTION; - const std::function onGoBack; - - // Network mode - NetworkMode networkMode = NetworkMode::JOIN_NETWORK; - bool isApMode = false; - - // Web server - owned by this activity - std::unique_ptr webServer; - - // 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 onNetworkModeSelected(NetworkMode mode); - void onWifiSelectionComplete(bool connected); - void startAccessPoint(); - void startWebServer(); - void stopWebServer(); - - public: - explicit CrossPointWebServerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::function& onGoBack) - : ActivityWithSubactivity("CrossPointWebServer", renderer, mappedInput), onGoBack(onGoBack) {} - void onEnter() override; - void onExit() override; - void loop() override; - bool skipLoopDelay() override { return webServer && webServer->isRunning(); } -}; diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/FileTransferActivity.cpp similarity index 61% rename from src/activities/network/CrossPointWebServerActivity.cpp rename to src/activities/network/FileTransferActivity.cpp index 372516db..b5f534a7 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/FileTransferActivity.cpp @@ -1,4 +1,4 @@ -#include "CrossPointWebServerActivity.h" +#include "FileTransferActivity.h" #include #include @@ -29,12 +29,12 @@ DNSServer* dnsServer = nullptr; constexpr uint16_t DNS_PORT = 53; } // namespace -void CrossPointWebServerActivity::taskTrampoline(void* param) { - auto* self = static_cast(param); +void FileTransferActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); self->displayTaskLoop(); } -void CrossPointWebServerActivity::onEnter() { +void FileTransferActivity::onEnter() { ActivityWithSubactivity::onEnter(); Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onEnter: %d bytes\n", millis(), ESP.getFreeHeap()); @@ -42,7 +42,7 @@ void CrossPointWebServerActivity::onEnter() { renderingMutex = xSemaphoreCreateMutex(); // Reset state - state = WebServerActivityState::MODE_SELECTION; + state = FileTransferActivityState::MODE_SELECTION; networkMode = NetworkMode::JOIN_NETWORK; isApMode = false; connectedIP.clear(); @@ -50,7 +50,7 @@ void CrossPointWebServerActivity::onEnter() { lastHandleClientTime = 0; updateRequired = true; - xTaskCreate(&CrossPointWebServerActivity::taskTrampoline, "WebServerActivityTask", + xTaskCreate(&FileTransferActivity::taskTrampoline, "WebServerActivityTask", 2048, // Stack size this, // Parameters 1, // Priority @@ -65,15 +65,16 @@ void CrossPointWebServerActivity::onEnter() { )); } -void CrossPointWebServerActivity::onExit() { +void FileTransferActivity::onExit() { ActivityWithSubactivity::onExit(); Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit start: %d bytes\n", millis(), ESP.getFreeHeap()); - state = WebServerActivityState::SHUTTING_DOWN; + state = FileTransferActivityState::SHUTTING_DOWN; - // Stop the web server first (before disconnecting WiFi) - stopWebServer(); + // Stop the file transfer servers first (before disconnecting WiFi) + stopHttpServer(); + stopFtpServer(); // Stop mDNS MDNS.end(); @@ -127,7 +128,7 @@ void CrossPointWebServerActivity::onExit() { Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit end: %d bytes\n", millis(), ESP.getFreeHeap()); } -void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) { +void FileTransferActivity::onNetworkModeSelected(const NetworkMode mode) { Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(), mode == NetworkMode::JOIN_NETWORK ? "Join Network" : "Create Hotspot"); @@ -146,24 +147,41 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) // Exit mode selection subactivity exitActivity(); - if (mode == NetworkMode::JOIN_NETWORK) { + // Launch protocol selection subactivity + state = FileTransferActivityState::PROTOCOL_SELECTION; + Serial.printf("[%lu] [WEBACT] Launching ProtocolSelectionActivity...\n", millis()); + enterNewActivity(new ProtocolSelectionActivity( + renderer, mappedInput, [this](const FileTransferProtocol protocol) { onProtocolSelected(protocol); }, + [this]() { onGoBack(); })); +} + +void FileTransferActivity::onProtocolSelected(const FileTransferProtocol protocol) { + Serial.printf("[%lu] [WEBACT] Protocol selected: %s\n", millis(), + protocol == FileTransferProtocol::HTTP ? "HTTP" : "FTP"); + + selectedProtocol = protocol; + + // Exit protocol selection subactivity + exitActivity(); + + if (networkMode == NetworkMode::JOIN_NETWORK) { // STA mode - launch WiFi selection Serial.printf("[%lu] [WEBACT] Turning on WiFi (STA mode)...\n", millis()); WiFi.mode(WIFI_STA); - state = WebServerActivityState::WIFI_SELECTION; + state = FileTransferActivityState::WIFI_SELECTION; Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis()); enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, [this](const bool connected) { onWifiSelectionComplete(connected); })); } else { // AP mode - start access point - state = WebServerActivityState::AP_STARTING; + state = FileTransferActivityState::AP_STARTING; updateRequired = true; startAccessPoint(); } } -void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) { +void FileTransferActivity::onWifiSelectionComplete(const bool connected) { Serial.printf("[%lu] [WEBACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected); if (connected) { @@ -179,19 +197,19 @@ void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) Serial.printf("[%lu] [WEBACT] mDNS started: http://%s.local/\n", millis(), AP_HOSTNAME); } - // Start the web server - startWebServer(); + // Start the file transfer server + startServer(); } else { // User cancelled - go back to mode selection exitActivity(); - state = WebServerActivityState::MODE_SELECTION; + state = FileTransferActivityState::MODE_SELECTION; enterNewActivity(new NetworkModeSelectionActivity( renderer, mappedInput, [this](const NetworkMode mode) { onNetworkModeSelected(mode); }, [this]() { onGoBack(); })); } } -void CrossPointWebServerActivity::startAccessPoint() { +void FileTransferActivity::startAccessPoint() { Serial.printf("[%lu] [WEBACT] Starting Access Point mode...\n", millis()); Serial.printf("[%lu] [WEBACT] [MEM] Free heap before AP start: %d bytes\n", millis(), ESP.getFreeHeap()); @@ -243,45 +261,78 @@ void CrossPointWebServerActivity::startAccessPoint() { Serial.printf("[%lu] [WEBACT] [MEM] Free heap after AP start: %d bytes\n", millis(), ESP.getFreeHeap()); - // Start the web server - startWebServer(); + // Start the file transfer server + startServer(); } -void CrossPointWebServerActivity::startWebServer() { - Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis()); +void FileTransferActivity::startServer() { + if (selectedProtocol == FileTransferProtocol::HTTP) { + Serial.printf("[%lu] [WEBACT] Starting HTTP server...\n", millis()); - // Create the web server instance - webServer.reset(new CrossPointWebServer()); - webServer->begin(); + // Create the HTTP server instance + httpServer.reset(new CrossPointWebServer()); + httpServer->begin(); - if (webServer->isRunning()) { - state = WebServerActivityState::SERVER_RUNNING; - Serial.printf("[%lu] [WEBACT] Web server started successfully\n", millis()); + if (httpServer->isRunning()) { + state = FileTransferActivityState::SERVER_RUNNING; + serverStartTime = millis(); // Track when server started + Serial.printf("[%lu] [WEBACT] HTTP server started successfully\n", millis()); - // Force an immediate render since we're transitioning from a subactivity - // that had its own rendering task. We need to make sure our display is shown. - xSemaphoreTake(renderingMutex, portMAX_DELAY); - render(); - xSemaphoreGive(renderingMutex); - Serial.printf("[%lu] [WEBACT] Rendered File Transfer screen\n", millis()); + // Force an immediate render since we're transitioning from a subactivity + // that had its own rendering task. We need to make sure our display is shown. + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + Serial.printf("[%lu] [WEBACT] Rendered File Transfer screen\n", millis()); + } else { + Serial.printf("[%lu] [WEBACT] ERROR: Failed to start HTTP server!\n", millis()); + httpServer.reset(); + onGoBack(); + } } else { - Serial.printf("[%lu] [WEBACT] ERROR: Failed to start web server!\n", millis()); - webServer.reset(); - // Go back on error - onGoBack(); + Serial.printf("[%lu] [WEBACT] Starting FTP server...\n", millis()); + + // Create the FTP server instance + ftpServer.reset(new CrossPointFtpServer()); + ftpServer->begin(); + + if (ftpServer->isRunning()) { + state = FileTransferActivityState::SERVER_RUNNING; + serverStartTime = millis(); // Track when server started + Serial.printf("[%lu] [WEBACT] FTP server started successfully\n", millis()); + + // Force an immediate render + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + Serial.printf("[%lu] [WEBACT] Rendered File Transfer screen\n", millis()); + } else { + Serial.printf("[%lu] [WEBACT] ERROR: Failed to start FTP server!\n", millis()); + ftpServer.reset(); + onGoBack(); + } } } -void CrossPointWebServerActivity::stopWebServer() { - if (webServer && webServer->isRunning()) { - Serial.printf("[%lu] [WEBACT] Stopping web server...\n", millis()); - webServer->stop(); - Serial.printf("[%lu] [WEBACT] Web server stopped\n", millis()); +void FileTransferActivity::stopHttpServer() { + if (httpServer && httpServer->isRunning()) { + Serial.printf("[%lu] [WEBACT] Stopping HTTP server...\n", millis()); + httpServer->stop(); + Serial.printf("[%lu] [WEBACT] HTTP server stopped\n", millis()); } - webServer.reset(); + httpServer.reset(); } -void CrossPointWebServerActivity::loop() { +void FileTransferActivity::stopFtpServer() { + if (ftpServer && ftpServer->isRunning()) { + Serial.printf("[%lu] [WEBACT] Stopping FTP server...\n", millis()); + ftpServer->stop(); + Serial.printf("[%lu] [WEBACT] FTP server stopped\n", millis()); + } + ftpServer.reset(); +} + +void FileTransferActivity::loop() { if (subActivity) { // Forward loop to subactivity subActivity->loop(); @@ -289,15 +340,18 @@ void CrossPointWebServerActivity::loop() { } // Handle different states - if (state == WebServerActivityState::SERVER_RUNNING) { + if (state == FileTransferActivityState::SERVER_RUNNING) { // Handle DNS requests for captive portal (AP mode only) if (isApMode && dnsServer) { dnsServer->processNextRequest(); } - // Handle web server requests - call handleClient multiple times per loop + // Handle file transfer server requests - call handleClient multiple times per loop // to improve responsiveness and upload throughput - if (webServer && webServer->isRunning()) { + const bool httpRunning = httpServer && httpServer->isRunning(); + const bool ftpRunning = ftpServer && ftpServer->isRunning(); + + if (httpRunning || ftpRunning) { const unsigned long timeSinceLastHandleClient = millis() - lastHandleClientTime; // Log if there's a significant gap between handleClient calls (>100ms) @@ -307,12 +361,16 @@ void CrossPointWebServerActivity::loop() { } // Call handleClient multiple times to process pending requests faster - // This is critical for upload performance - HTTP file uploads send data + // This is critical for upload performance - file uploads send data // in chunks and each handleClient() call processes incoming data // Reduced from 10 to 3 to prevent watchdog timer issues constexpr int HANDLE_CLIENT_ITERATIONS = 3; - for (int i = 0; i < HANDLE_CLIENT_ITERATIONS && webServer->isRunning(); i++) { - webServer->handleClient(); + for (int i = 0; i < HANDLE_CLIENT_ITERATIONS; i++) { + if (httpRunning && httpServer->isRunning()) { + httpServer->handleClient(); + } else if (ftpRunning && ftpServer->isRunning()) { + ftpServer->handleClient(); + } // Feed the watchdog timer between iterations to prevent resets esp_task_wdt_reset(); // Yield to other tasks to prevent starvation @@ -321,6 +379,18 @@ void CrossPointWebServerActivity::loop() { lastHandleClientTime = millis(); } + // Check auto-shutdown timer if schedule is enabled + if (SETTINGS.scheduleEnabled && serverStartTime > 0) { + const unsigned long serverUptime = millis() - serverStartTime; + const unsigned long shutdownTimeout = SETTINGS.getAutoShutdownMs(); + + if (serverUptime >= shutdownTimeout) { + Serial.printf("[%lu] [WEBACT] Auto-shutdown triggered after %lu ms\n", millis(), serverUptime); + onGoBack(); + return; + } + } + // Handle exit on Back button if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onGoBack(); @@ -329,7 +399,7 @@ void CrossPointWebServerActivity::loop() { } } -void CrossPointWebServerActivity::displayTaskLoop() { +void FileTransferActivity::displayTaskLoop() { while (true) { if (updateRequired) { updateRequired = false; @@ -341,14 +411,14 @@ void CrossPointWebServerActivity::displayTaskLoop() { } } -void CrossPointWebServerActivity::render() const { +void FileTransferActivity::render() const { // Only render our own UI when server is running // Subactivities handle their own rendering - if (state == WebServerActivityState::SERVER_RUNNING) { + if (state == FileTransferActivityState::SERVER_RUNNING) { renderer.clearScreen(); renderServerRunning(); renderer.displayBuffer(); - } else if (state == WebServerActivityState::AP_STARTING) { + } 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, BOLD); @@ -378,12 +448,20 @@ void drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std } } -void CrossPointWebServerActivity::renderServerRunning() const { +void FileTransferActivity::renderServerRunning() const { + renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer", true, BOLD); + + if (selectedProtocol == FileTransferProtocol::HTTP) { + renderHttpServerRunning(); + } else { + renderFtpServerRunning(); + } +} + +void FileTransferActivity::renderHttpServerRunning() const { // Use consistent line spacing constexpr int LINE_SPACING = 28; // Space between lines - renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer", true, BOLD); - if (isApMode) { // AP mode display - center the content block int startY = 55; @@ -445,3 +523,67 @@ void CrossPointWebServerActivity::renderServerRunning() const { const auto labels = mappedInput.mapLabels("« Exit", "", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } + +void FileTransferActivity::renderFtpServerRunning() const { + // Use consistent line spacing + constexpr int LINE_SPACING = 28; // Space between lines + + if (isApMode) { + // AP mode display + int startY = 55; + + renderer.drawCenteredText(UI_10_FONT_ID, startY, "Hotspot Mode", true, 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 + std::string wifiConfig = std::string("WIFI:T:WPA;S:") + connectedSSID + ";P:" + "" + ";;"; + drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 4, wifiConfig); + + startY += 6 * 29 + 3 * LINE_SPACING; + + // Show FTP server info + renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 3, "FTP Server", true, BOLD); + + std::string ftpInfo = "ftp://" + connectedIP + "/"; + renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 4, ftpInfo.c_str(), true, BOLD); + + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "Connect with FTP client:"); + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 6, "Username: crosspoint"); + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 7, "Password: reader"); + } 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 FTP server info + renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 2, "FTP Server", true, BOLD); + + std::string ftpInfo = "ftp://" + connectedIP + "/"; + renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 3, ftpInfo.c_str(), true, BOLD); + + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Use FTP client to connect:"); + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "Username: crosspoint"); + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 6, "Password: reader"); + + // Show QR code for FTP URL + drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 8, ftpInfo); + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 7, "or scan QR code with your phone:"); + } + + 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..f9f53d25 --- /dev/null +++ b/src/activities/network/FileTransferActivity.h @@ -0,0 +1,89 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "NetworkModeSelectionActivity.h" +#include "ProtocolSelectionActivity.h" +#include "activities/ActivityWithSubactivity.h" +#include "network/CrossPointWebServer.h" +#include "network/CrossPointFtpServer.h" + +// File transfer activity states +enum class FileTransferActivityState { + MODE_SELECTION, // Choosing between Join Network and Create Hotspot + PROTOCOL_SELECTION, // Choosing between HTTP and FTP + WIFI_SELECTION, // WiFi selection subactivity is active (for Join Network mode) + AP_STARTING, // Starting Access Point mode + SERVER_RUNNING, // File transfer server is running and handling requests + SHUTTING_DOWN // Shutting down server and WiFi +}; + +/** + * FileTransferActivity is the entry point for file transfer functionality. + * It: + * - First presents a choice between "Join a Network" (STA) and "Create Hotspot" (AP) + * - For STA mode: Launches WifiSelectionActivity to connect to an existing network + * - For AP mode: Creates an Access Point that clients can connect to + * - Starts the file transfer server (HTTP or FTP) when connected + * - Handles client requests in its loop() function + * - Cleans up the server and shuts down WiFi on exit + */ +class FileTransferActivity final : public ActivityWithSubactivity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + FileTransferActivityState state = FileTransferActivityState::MODE_SELECTION; + const std::function onGoBack; + + // Network mode + NetworkMode networkMode = NetworkMode::JOIN_NETWORK; + bool isApMode = false; + + // Transfer protocol + FileTransferProtocol selectedProtocol = FileTransferProtocol::HTTP; + + // File transfer servers - owned by this activity + std::unique_ptr httpServer; + 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; + + // Auto-shutdown tracking + unsigned long serverStartTime = 0; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + void renderServerRunning() const; + void renderHttpServerRunning() const; + void renderFtpServerRunning() const; + + void onNetworkModeSelected(NetworkMode mode); + void onProtocolSelected(FileTransferProtocol protocol); + void onWifiSelectionComplete(bool connected); + void startAccessPoint(); + void startServer(); + void stopHttpServer(); + void stopFtpServer(); + + 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 (httpServer && httpServer->isRunning()) || (ftpServer && ftpServer->isRunning()); + } +}; diff --git a/src/activities/network/ProtocolSelectionActivity.cpp b/src/activities/network/ProtocolSelectionActivity.cpp new file mode 100644 index 00000000..2afe9a62 --- /dev/null +++ b/src/activities/network/ProtocolSelectionActivity.cpp @@ -0,0 +1,129 @@ +#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 (Web Browser)", "FTP (File Client)"}; +const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = {"Upload/download via web browser", + "Upload/download via FTP client"}; +} // 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, "ProtocolSelectTask", + 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 back button - cancel + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + onCancel(); + return; + } + + // 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", true, BOLD); + + // Draw subtitle + renderer.drawCenteredText(UI_10_FONT_ID, 50, "Select transfer 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("« Back", "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..4cd1eef3 --- /dev/null +++ b/src/activities/network/ProtocolSelectionActivity.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include +#include + +#include + +#include "../Activity.h" + +// Enum for file transfer protocol selection +enum class FileTransferProtocol { HTTP, FTP }; + +/** + * ProtocolSelectionActivity presents the user with a choice: + * - "HTTP (Web Browser)" - Transfer files via web browser + * - "FTP (File Client)" - Transfer files via FTP client + * + * The onProtocolSelected callback is called with the user's choice. + * The onCancel callback is called if the user presses back. + */ +class ProtocolSelectionActivity final : public Activity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + int selectedIndex = 0; + bool updateRequired = false; + const std::function onProtocolSelected; + const std::function onCancel; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + + public: + explicit ProtocolSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onProtocolSelected, + const std::function& onCancel) + : Activity("ProtocolSelection", renderer, mappedInput), onProtocolSelected(onProtocolSelected), onCancel(onCancel) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index ab1e96b7..12ec3ca5 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -61,7 +61,7 @@ void WifiSelectionActivity::onExit() { WiFi.scanDelete(); Serial.printf("[%lu] [WIFI] [MEM] Free heap after scanDelete: %d bytes\n", millis(), ESP.getFreeHeap()); - // Note: We do NOT disconnect WiFi here - the parent activity (CrossPointWebServerActivity) + // Note: We do NOT disconnect WiFi here - the parent activity (FileTransferActivity) // manages WiFi connection state. We just clean up the scan and task. // Acquire mutex before deleting task to ensure task isn't using it diff --git a/src/activities/settings/ScheduleSettingsActivity.cpp b/src/activities/settings/ScheduleSettingsActivity.cpp new file mode 100644 index 00000000..f5c1629c --- /dev/null +++ b/src/activities/settings/ScheduleSettingsActivity.cpp @@ -0,0 +1,182 @@ +#include "ScheduleSettingsActivity.h" + +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "fontIds.h" + +namespace { +constexpr int SETTINGS_COUNT = 6; +const char* SETTING_NAMES[SETTINGS_COUNT] = { + "Schedule Enabled", + "Frequency", + "Schedule Time", + "Auto-Shutdown", + "Protocol", + "Network Mode" +}; +} // namespace + +void ScheduleSettingsActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void ScheduleSettingsActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + selectedIndex = 0; + updateRequired = true; + + xTaskCreate(&ScheduleSettingsActivity::taskTrampoline, "ScheduleSettingsTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void ScheduleSettingsActivity::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 ScheduleSettingsActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + SETTINGS.saveToFile(); + onGoBack(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + toggleCurrentSetting(); + updateRequired = true; + return; + } + + // Handle navigation + if (mappedInput.wasPressed(MappedInputManager::Button::Up) || + mappedInput.wasPressed(MappedInputManager::Button::Left)) { + selectedIndex = (selectedIndex > 0) ? (selectedIndex - 1) : (SETTINGS_COUNT - 1); + updateRequired = true; + } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || + mappedInput.wasPressed(MappedInputManager::Button::Right)) { + selectedIndex = (selectedIndex + 1) % SETTINGS_COUNT; + updateRequired = true; + } +} + +void ScheduleSettingsActivity::toggleCurrentSetting() { + switch (selectedIndex) { + case 0: // Schedule Enabled + SETTINGS.scheduleEnabled = !SETTINGS.scheduleEnabled; + break; + case 1: // Frequency + SETTINGS.scheduleFrequency = (SETTINGS.scheduleFrequency + 1) % 7; + break; + case 2: // Schedule Time (hour) + SETTINGS.scheduleHour = (SETTINGS.scheduleHour + 1) % 24; + break; + case 3: // Auto-Shutdown + SETTINGS.scheduleAutoShutdown = (SETTINGS.scheduleAutoShutdown + 1) % 6; + break; + case 4: // Protocol + SETTINGS.scheduleProtocol = (SETTINGS.scheduleProtocol + 1) % 2; + break; + case 5: // Network Mode + SETTINGS.scheduleNetworkMode = (SETTINGS.scheduleNetworkMode + 1) % 2; + break; + } + SETTINGS.saveToFile(); +} + +void ScheduleSettingsActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void ScheduleSettingsActivity::render() const { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Draw header + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Schedule Settings", true, BOLD); + renderer.drawCenteredText(SMALL_FONT_ID, 40, "Auto-start file transfer server"); + + // Draw selection + renderer.fillRect(0, 70 + selectedIndex * 30 - 2, pageWidth - 1, 30); + + // Draw settings + const char* frequencyNames[] = {"1 hour", "2 hours", "3 hours", "6 hours", "12 hours", "24 hours", "Scheduled"}; + const char* shutdownNames[] = {"5 min", "10 min", "20 min", "30 min", "60 min", "120 min"}; + const char* protocolNames[] = {"HTTP", "FTP"}; + const char* networkModeNames[] = {"Join Network", "Create Hotspot"}; + + for (int i = 0; i < SETTINGS_COUNT; i++) { + const int settingY = 70 + i * 30; + const bool isSelected = (i == selectedIndex); + + // Draw setting name + renderer.drawText(UI_10_FONT_ID, 20, settingY, SETTING_NAMES[i], !isSelected); + + // Draw value + std::string valueText; + switch (i) { + case 0: // Schedule Enabled + valueText = SETTINGS.scheduleEnabled ? "ON" : "OFF"; + break; + case 1: // Frequency + valueText = frequencyNames[SETTINGS.scheduleFrequency]; + break; + case 2: { // Schedule Time + char timeStr[6]; + snprintf(timeStr, sizeof(timeStr), "%02d:00", SETTINGS.scheduleHour); + valueText = timeStr; + break; + } + case 3: // Auto-Shutdown + valueText = shutdownNames[SETTINGS.scheduleAutoShutdown]; + break; + case 4: // Protocol + valueText = protocolNames[SETTINGS.scheduleProtocol]; + break; + case 5: // Network Mode + valueText = networkModeNames[SETTINGS.scheduleNetworkMode]; + break; + } + + 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(), !isSelected); + } + + // Draw info text + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 100, + SETTINGS.scheduleFrequency == 6 ? "Server starts at scheduled time" : "Server starts at intervals"); + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 80, + "and auto-shuts down after timeout"); + + // Draw help text + const auto labels = mappedInput.mapLabels("« Save", "Toggle", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/ScheduleSettingsActivity.h b/src/activities/settings/ScheduleSettingsActivity.h new file mode 100644 index 00000000..4bb9222d --- /dev/null +++ b/src/activities/settings/ScheduleSettingsActivity.h @@ -0,0 +1,33 @@ +#pragma once +#include +#include +#include + +#include + +#include "activities/Activity.h" + +/** + * ScheduleSettingsActivity allows users to configure automatic file transfer server scheduling. + * Users can set up recurring schedules (hourly, daily) or specific times throughout the week. + */ +class ScheduleSettingsActivity final : public Activity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + int selectedIndex = 0; // Currently selected option + const std::function onGoBack; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + void toggleCurrentSetting(); + + public: + explicit ScheduleSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onGoBack) + : Activity("ScheduleSettings", renderer, mappedInput), onGoBack(onGoBack) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 6567e672..ad89ecda 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -6,11 +6,12 @@ #include "FolderPickerActivity.h" #include "MappedInputManager.h" #include "OtaUpdateActivity.h" +#include "ScheduleSettingsActivity.h" #include "fontIds.h" // Define the static settings list namespace { -constexpr int settingsCount = 17; +constexpr int settingsCount = 18; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, @@ -50,6 +51,7 @@ const SettingInfo settingsList[settingsCount] = { {"Root", "Custom", "Last Used"}}, {"Choose Custom Folder", SettingType::ACTION, nullptr, {}}, {"Bluetooth", SettingType::TOGGLE, &CrossPointSettings::bluetoothEnabled, {}}, + {"File Transfer Schedule", SettingType::ACTION, nullptr, {}}, {"Check for updates", SettingType::ACTION, nullptr, {}}, }; } // namespace @@ -167,6 +169,14 @@ void SettingsActivity::toggleCurrentSetting() { }, "/")); // Start from root directory xSemaphoreGive(renderingMutex); + } else if (std::string(setting.name) == "File Transfer Schedule") { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new ScheduleSettingsActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); } } else { // Only toggle if it's a toggle type and has a value pointer diff --git a/src/main.cpp b/src/main.cpp index e2336bb5..1bcb4e61 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -16,7 +16,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" @@ -220,7 +220,63 @@ void onGoToFileTransfer() { enterNewActivity(new BleFileTransferActivity(renderer, mappedInputManager, onGoHome)); } else { Serial.printf("[%lu] [ ] Starting WiFi file transfer (Bluetooth disabled)\n", millis()); - enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome)); + enterNewActivity(new FileTransferActivity(renderer, mappedInputManager, onGoHome)); + } +} + +// Check if scheduled file transfer should be triggered +void checkScheduledFileTransfer() { + // Only check if scheduling is enabled + if (!SETTINGS.scheduleEnabled) { + return; + } + + // Don't start if Bluetooth is enabled (conflicts with WiFi) + if (SETTINGS.bluetoothEnabled) { + Serial.printf("[%lu] [SCH] Scheduled file transfer skipped - Bluetooth enabled\n", millis()); + return; + } + + const unsigned long currentTime = millis(); + bool shouldStart = false; + + if (SETTINGS.scheduleFrequency == 6) { + // Scheduled time mode - check if current hour matches scheduled hour + // Note: This is a simple check. For a real-time clock, you'd need an RTC module. + // Here we approximate based on uptime and assume device wakes at specific times. + // This is a placeholder - for production, integrate with an RTC or NTP time sync. + + // For now, we trigger on first wake if not already triggered today + // A proper implementation would need actual time tracking + const unsigned long timeSinceLastServer = currentTime - APP_STATE.lastScheduledServerTime; + const unsigned long oneDay = 24UL * 60UL * 60UL * 1000UL; + + if (APP_STATE.lastScheduledServerTime == 0 || timeSinceLastServer >= oneDay) { + shouldStart = true; + Serial.printf("[%lu] [SCH] Scheduled time mode - triggering (configured for %02d:00)\n", + millis(), SETTINGS.scheduleHour); + } + } else { + // Interval mode + const unsigned long scheduleInterval = SETTINGS.getScheduleIntervalMs(); + const unsigned long timeSinceLastServer = currentTime - APP_STATE.lastScheduledServerTime; + + // Check if it's time to start the server + // On first boot, lastScheduledServerTime will be 0, so we check if it's been at least the interval + if (APP_STATE.lastScheduledServerTime == 0 || timeSinceLastServer >= scheduleInterval) { + shouldStart = true; + Serial.printf("[%lu] [SCH] Interval mode - triggering (interval: %lu ms, last: %lu ms ago)\n", + millis(), scheduleInterval, timeSinceLastServer); + } + } + + if (shouldStart) { + // Update the last scheduled server time + APP_STATE.lastScheduledServerTime = currentTime; + APP_STATE.saveToFile(); + + // Start the file transfer activity + onGoToFileTransfer(); } } @@ -296,6 +352,10 @@ void setup() { enterNewActivity(new BootActivity(renderer, mappedInputManager)); APP_STATE.loadFromFile(); + + // Check if scheduled file transfer should be triggered + checkScheduledFileTransfer(); + if (APP_STATE.openEpubPath.empty()) { onGoHome(); } else { diff --git a/src/network/CrossPointFtpServer.cpp b/src/network/CrossPointFtpServer.cpp new file mode 100644 index 00000000..75f0527d --- /dev/null +++ b/src/network/CrossPointFtpServer.cpp @@ -0,0 +1,111 @@ +#include "CrossPointFtpServer.h" + +#include + +namespace { +// FTP server credentials +constexpr const char* FTP_USERNAME = "crosspoint"; +constexpr const char* FTP_PASSWORD = "reader"; +} // namespace + +CrossPointFtpServer::CrossPointFtpServer() {} + +CrossPointFtpServer::~CrossPointFtpServer() { stop(); } + +void CrossPointFtpServer::begin() { + if (running) { + Serial.printf("[%lu] [FTP] FTP server already running\n", millis()); + return; + } + + // Check if we have a valid network connection (either STA connected or AP mode) + const wifi_mode_t wifiMode = WiFi.getMode(); + const bool isStaConnected = (wifiMode & WIFI_MODE_STA) && (WiFi.status() == WL_CONNECTED); + const bool isInApMode = (wifiMode & WIFI_MODE_AP) && (WiFi.softAPgetStationNum() >= 0); // AP is running + + if (!isStaConnected && !isInApMode) { + Serial.printf("[%lu] [FTP] Cannot start FTP server - no valid network (mode=%d, status=%d)\n", millis(), wifiMode, + WiFi.status()); + return; + } + + // Store AP mode flag for later use + apMode = isInApMode; + + Serial.printf("[%lu] [FTP] [MEM] Free heap before begin: %d bytes\n", millis(), ESP.getFreeHeap()); + Serial.printf("[%lu] [FTP] Network mode: %s\n", millis(), apMode ? "AP" : "STA"); + + Serial.printf("[%lu] [FTP] Creating FTP server on port 21...\n", millis()); + + // Create FTP server instance + ftpServer.reset(new ::FtpServer()); + + // Disable WiFi sleep to improve responsiveness and prevent 'unreachable' errors. + // This is critical for reliable FTP server operation on ESP32. + WiFi.setSleep(false); + + Serial.printf("[%lu] [FTP] [MEM] Free heap after FTPServer allocation: %d bytes\n", millis(), ESP.getFreeHeap()); + + if (!ftpServer) { + Serial.printf("[%lu] [FTP] Failed to create FTPServer!\n", millis()); + return; + } + + // Initialize FTP server with credentials + ftpServer->begin(FTP_USERNAME, FTP_PASSWORD); + running = true; + + Serial.printf("[%lu] [FTP] FTP server started on port 21\n", millis()); + // Show the correct IP based on network mode + const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); + Serial.printf("[%lu] [FTP] Access at ftp://%s/\n", millis(), ipAddr.c_str()); + Serial.printf("[%lu] [FTP] Username: %s\n", millis(), FTP_USERNAME); + Serial.printf("[%lu] [FTP] Password: %s\n", millis(), FTP_PASSWORD); + Serial.printf("[%lu] [FTP] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap()); +} + +void CrossPointFtpServer::stop() { + if (!running || !ftpServer) { + Serial.printf("[%lu] [FTP] stop() called but already stopped (running=%d, ftpServer=%p)\n", millis(), running, + ftpServer.get()); + return; + } + + Serial.printf("[%lu] [FTP] STOP INITIATED - setting running=false first\n", millis()); + running = false; // Set this FIRST to prevent handleClient from using server + + Serial.printf("[%lu] [FTP] [MEM] Free heap before stop: %d bytes\n", millis(), ESP.getFreeHeap()); + + // Add delay to allow any in-flight handleClient() calls to complete + delay(100); + Serial.printf("[%lu] [FTP] Waited 100ms for handleClient to finish\n", millis()); + + // SimpleFTPServer doesn't have explicit stop method, just delete + ftpServer.reset(); + Serial.printf("[%lu] [FTP] FTP server stopped and deleted\n", millis()); + Serial.printf("[%lu] [FTP] [MEM] Free heap after delete server: %d bytes\n", millis(), ESP.getFreeHeap()); + Serial.printf("[%lu] [FTP] [MEM] Free heap final: %d bytes\n", millis(), ESP.getFreeHeap()); +} + +void CrossPointFtpServer::handleClient() const { + static unsigned long lastDebugPrint = 0; + + // Check running flag FIRST before accessing server + if (!running) { + return; + } + + // Double-check server pointer is valid + if (!ftpServer) { + Serial.printf("[%lu] [FTP] WARNING: handleClient called with null server!\n", millis()); + return; + } + + // Print debug every 10 seconds to confirm handleClient is being called + if (millis() - lastDebugPrint > 10000) { + Serial.printf("[%lu] [FTP] handleClient active, server running on port 21\n", millis()); + lastDebugPrint = millis(); + } + + ftpServer->handleFTP(); +} diff --git a/src/network/CrossPointFtpServer.h b/src/network/CrossPointFtpServer.h new file mode 100644 index 00000000..8fa63068 --- /dev/null +++ b/src/network/CrossPointFtpServer.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +// Must include SDCardManager before SimpleFTPServer to get SdFat +#include + +// Configure SimpleFTPServer to use SdFat 2.x BEFORE including the library +#define STORAGE_TYPE_SDFAT2 +#include + +class CrossPointFtpServer { + public: + CrossPointFtpServer(); + ~CrossPointFtpServer(); + + // Start the FTP server (call after WiFi is connected) + void begin(); + + // Stop the FTP server + void stop(); + + // Call this periodically to handle client requests + void handleClient() const; + + // Check if server is running + bool isRunning() const { return running; } + + // Get the port number + uint16_t getPort() const { return 21; } + + private: + std::unique_ptr<::FtpServer> ftpServer; + bool running = false; + bool apMode = false; // true when running in AP mode, false for STA mode +};