Add FTP server support with protocol selection and QR codes

This commit integrates FTP server functionality alongside the existing HTTP
server, allowing users to choose their preferred file transfer protocol.

Key changes:
- Added SimpleFTPServer library dependency (configured for SdFat2)
- Created CrossPointFtpServer wrapper for FTP server management
- Added network credentials to CrossPointSettings (ftpUsername, ftpPassword, httpUsername, httpPassword)
- Created ProtocolSelectionActivity for HTTP/FTP choice
- Created FileTransferActivity to replace CrossPointWebServerActivity
  - Supports both HTTP and FTP protocols
  - Shows QR codes for WiFi credentials (AP mode) and server URLs
  - Displays FTP credentials on screen for easy access
- Updated main.cpp to use FileTransferActivity instead of CrossPointWebServerActivity

The FTP server provides an alternative file transfer method that works well
with dedicated FTP clients and offers better performance for large file transfers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
altsysrq 2026-01-05 21:01:33 -06:00
parent 14972b34cb
commit 5bf721ae42
10 changed files with 834 additions and 3 deletions

View File

@ -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

View File

@ -12,7 +12,7 @@ CrossPointSettings CrossPointSettings::instance;
namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
// Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 13;
constexpr uint8_t SETTINGS_COUNT = 17;
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace
@ -40,6 +40,10 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, paragraphAlignment);
serialization::writePod(outputFile, sleepTimeout);
serialization::writePod(outputFile, refreshFrequency);
serialization::writeString(outputFile, ftpUsername);
serialization::writeString(outputFile, ftpPassword);
serialization::writeString(outputFile, httpUsername);
serialization::writeString(outputFile, httpPassword);
outputFile.close();
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
@ -92,6 +96,14 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, refreshFrequency);
if (++settingsRead >= fileSettingsCount) break;
serialization::readString(inputFile, ftpUsername);
if (++settingsRead >= fileSettingsCount) break;
serialization::readString(inputFile, ftpPassword);
if (++settingsRead >= fileSettingsCount) break;
serialization::readString(inputFile, httpUsername);
if (++settingsRead >= fileSettingsCount) break;
serialization::readString(inputFile, httpPassword);
if (++settingsRead >= fileSettingsCount) break;
} while (false);
inputFile.close();

View File

@ -1,6 +1,7 @@
#pragma once
#include <cstdint>
#include <iosfwd>
#include <string>
class CrossPointSettings {
private:
@ -75,6 +76,12 @@ class CrossPointSettings {
// E-ink refresh frequency (default 15 pages)
uint8_t refreshFrequency = REFRESH_15;
// Network credentials for FTP and HTTP servers
std::string ftpUsername = "crosspoint";
std::string ftpPassword = "reader";
std::string httpUsername = "crosspoint";
std::string httpPassword = "reader";
~CrossPointSettings() = default;
// Get singleton instance

View File

@ -0,0 +1,487 @@
#include "FileTransferActivity.h"
#include <DNSServer.h>
#include <ESPmDNS.h>
#include <GfxRenderer.h>
#include <WiFi.h>
#include <qrcode.h>
#include <cstddef>
#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<FileTransferActivity*>(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<WifiSelectionActivity*>(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);
}

View File

@ -0,0 +1,78 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <memory>
#include <string>
#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<void()> 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<CrossPointWebServer> webServer;
std::unique_ptr<CrossPointFtpServer> 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<void()>& 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());
}
};

View File

@ -0,0 +1,123 @@
#include "ProtocolSelectionActivity.h"
#include <GfxRenderer.h>
#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<ProtocolSelectionActivity*>(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();
}

View File

@ -0,0 +1,30 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#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<void(FileTransferProtocol)> onProtocolSelected;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
public:
explicit ProtocolSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(FileTransferProtocol)>& onProtocolSelected)
: Activity("ProtocolSelection", renderer, mappedInput), onProtocolSelected(onProtocolSelected) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -14,7 +14,7 @@
#include "activities/boot_sleep/BootActivity.h"
#include "activities/boot_sleep/SleepActivity.h"
#include "activities/home/HomeActivity.h"
#include "activities/network/CrossPointWebServerActivity.h"
#include "activities/network/FileTransferActivity.h"
#include "activities/reader/ReaderActivity.h"
#include "activities/settings/SettingsActivity.h"
#include "activities/util/FullScreenMessageActivity.h"
@ -214,7 +214,7 @@ void onContinueReading() { onGoToReader(APP_STATE.openEpubPath); }
void onGoToFileTransfer() {
exitActivity();
enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
enterNewActivity(new FileTransferActivity(renderer, mappedInputManager, onGoHome));
}
void onGoToSettings() {

View File

@ -0,0 +1,65 @@
#include "CrossPointFtpServer.h"
#include <HardwareSerial.h>
#include <SDCardManager.h>
#include <WiFi.h>
#include <esp_wifi.h>
#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();
}
}

View File

@ -0,0 +1,28 @@
#pragma once
// Configure SimpleFTPServer to use SdFat2 instead of SD library
#define DEFAULT_STORAGE_TYPE_ESP32 2 // STORAGE_SDFAT2
#include <SimpleFTPServer.h>
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; }
};