From 9f4f71fabe1ddd5e7bb68f58e7831161e354bb5e Mon Sep 17 00:00:00 2001 From: Brendan O'Leary Date: Mon, 22 Dec 2025 01:24:14 -0500 Subject: [PATCH] Add AP mode option for file transfers (#98) ## Summary * **What is the goal of this PR?** Adds WiFi Access Point (AP) mode support for File Transfer, allowing the device to create its own WiFi network that users can connect to directly - useful when no existing WiFi network is available. And in my experience is faster when the device is right next to your laptop (but maybe further from your wifi) * **What changes are included?** - New `NetworkModeSelectionActivity` - an interstitial screen asking users to choose between: - "Join a Network" - connects to an existing WiFi network (existing behavior) - "Create Hotspot" - creates a WiFi access point named "CrossPoint-Reader" - Modified `CrossPointWebServerActivity` to: - Launch the network mode selection screen before proceeding - Support starting an Access Point with mDNS (`crosspoint.local`) and DNS server for captive portal behavior - Display appropriate connection info for both modes - Modified `CrossPointWebServer` to support starting when WiFi is in AP mode (not just STA connected mode) ## Additional Context * **AP Mode Details**: The device creates an open WiFi network named "CrossPoint-Reader". Once connected, users can access the file transfer page at `http://crosspoint.local/` or `http://192.168.4.1/` * **DNS Captive Portal**: A DNS server redirects all domain requests to the device's IP, enabling captive portal behavior on some devices * **mDNS**: Hostname resolution via `crosspoint.local` is enabled for both AP and STA modes * **No breaking changes**: The "Join a Network" option preserves the existing WiFi connection flow * **Memory impact**: Minimal - the AP mode uses roughly the same resources as STA mode --- .../network/CrossPointWebServerActivity.cpp | 228 +++++++++++++++--- .../network/CrossPointWebServerActivity.h | 21 +- .../network/NetworkModeSelectionActivity.cpp | 128 ++++++++++ .../network/NetworkModeSelectionActivity.h | 41 ++++ src/network/CrossPointWebServer.cpp | 26 +- src/network/CrossPointWebServer.h | 1 + 6 files changed, 404 insertions(+), 41 deletions(-) create mode 100644 src/activities/network/NetworkModeSelectionActivity.cpp create mode 100644 src/activities/network/NetworkModeSelectionActivity.h diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 5bf571a9..b9c911e2 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -1,12 +1,28 @@ #include "CrossPointWebServerActivity.h" +#include +#include #include #include #include +#include "NetworkModeSelectionActivity.h" #include "WifiSelectionActivity.h" #include "config.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; +} // namespace + void CrossPointWebServerActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); @@ -20,7 +36,9 @@ void CrossPointWebServerActivity::onEnter() { renderingMutex = xSemaphoreCreateMutex(); // Reset state - state = WebServerActivityState::WIFI_SELECTION; + state = WebServerActivityState::MODE_SELECTION; + networkMode = NetworkMode::JOIN_NETWORK; + isApMode = false; connectedIP.clear(); connectedSSID.clear(); lastHandleClientTime = 0; @@ -33,14 +51,12 @@ void CrossPointWebServerActivity::onEnter() { &displayTaskHandle // Task handle ); - // Turn on WiFi immediately - Serial.printf("[%lu] [WEBACT] Turning on WiFi...\n", millis()); - WiFi.mode(WIFI_STA); - - // Launch WiFi selection subactivity - Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis()); - enterNewActivity(new WifiSelectionActivity(renderer, inputManager, - [this](const bool connected) { onWifiSelectionComplete(connected); })); + // Launch network mode selection subactivity + Serial.printf("[%lu] [WEBACT] Launching NetworkModeSelectionActivity...\n", millis()); + enterNewActivity(new NetworkModeSelectionActivity( + renderer, inputManager, [this](const NetworkMode mode) { onNetworkModeSelected(mode); }, + [this]() { onGoBack(); } // Cancel goes back to home + )); } void CrossPointWebServerActivity::onExit() { @@ -53,14 +69,30 @@ void CrossPointWebServerActivity::onExit() { // Stop the web server first (before disconnecting WiFi) stopWebServer(); + // Stop mDNS + MDNS.end(); + + // Stop DNS server if running (AP mode) + if (dnsServer) { + Serial.printf("[%lu] [WEBACT] Stopping DNS server...\n", millis()); + dnsServer->stop(); + delete dnsServer; + dnsServer = nullptr; + } + // CRITICAL: Wait for LWIP stack to flush any pending packets Serial.printf("[%lu] [WEBACT] Waiting 500ms for network stack to flush pending packets...\n", millis()); delay(500); // Disconnect WiFi gracefully - Serial.printf("[%lu] [WEBACT] Disconnecting WiFi (graceful)...\n", millis()); - WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame - delay(100); // Allow disconnect frame to be sent + if (isApMode) { + Serial.printf("[%lu] [WEBACT] Stopping WiFi AP...\n", millis()); + WiFi.softAPdisconnect(true); + } else { + Serial.printf("[%lu] [WEBACT] 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] [WEBACT] Setting WiFi mode OFF...\n", millis()); WiFi.mode(WIFI_OFF); @@ -89,6 +121,33 @@ 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) { + Serial.printf("[%lu] [WEBACT] 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] [WEBACT] Turning on WiFi (STA mode)...\n", millis()); + WiFi.mode(WIFI_STA); + + state = WebServerActivityState::WIFI_SELECTION; + Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis()); + enterNewActivity(new WifiSelectionActivity(renderer, inputManager, + [this](const bool connected) { onWifiSelectionComplete(connected); })); + } else { + // AP mode - start access point + state = WebServerActivityState::AP_STARTING; + updateRequired = true; + startAccessPoint(); + } +} + void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) { Serial.printf("[%lu] [WEBACT] WifiSelectionActivity completed, connected=%d\n", millis(), connected); @@ -96,17 +155,83 @@ void CrossPointWebServerActivity::onWifiSelectionComplete(const bool 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] [WEBACT] mDNS started: http://%s.local/\n", millis(), AP_HOSTNAME); + } + // Start the web server startWebServer(); } else { - // User cancelled - go back - onGoBack(); + // User cancelled - go back to mode selection + exitActivity(); + state = WebServerActivityState::MODE_SELECTION; + enterNewActivity(new NetworkModeSelectionActivity( + renderer, inputManager, [this](const NetworkMode mode) { onNetworkModeSelected(mode); }, + [this]() { onGoBack(); })); } } +void CrossPointWebServerActivity::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()); + + // 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] [WEBACT] 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] [WEBACT] Access Point started!\n", millis()); + Serial.printf("[%lu] [WEBACT] SSID: %s\n", millis(), AP_SSID); + Serial.printf("[%lu] [WEBACT] IP: %s\n", millis(), connectedIP.c_str()); + + // Start mDNS for hostname resolution + if (MDNS.begin(AP_HOSTNAME)) { + Serial.printf("[%lu] [WEBACT] mDNS started: http://%s.local/\n", millis(), AP_HOSTNAME); + } else { + Serial.printf("[%lu] [WEBACT] 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] [WEBACT] DNS server started for captive portal\n", millis()); + + Serial.printf("[%lu] [WEBACT] [MEM] Free heap after AP start: %d bytes\n", millis(), ESP.getFreeHeap()); + + // Start the web server + startWebServer(); +} + void CrossPointWebServerActivity::startWebServer() { Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis()); @@ -150,6 +275,11 @@ void CrossPointWebServerActivity::loop() { // Handle different states if (state == WebServerActivityState::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 // to improve responsiveness and upload throughput if (webServer && webServer->isRunning()) { @@ -193,35 +323,71 @@ void CrossPointWebServerActivity::displayTaskLoop() { void CrossPointWebServerActivity::render() const { // Only render our own UI when server is running - // WiFi selection handles its own rendering + // Subactivities handle their own rendering if (state == WebServerActivityState::SERVER_RUNNING) { renderer.clearScreen(); renderServerRunning(); renderer.displayBuffer(); + } else if (state == WebServerActivityState::AP_STARTING) { + renderer.clearScreen(); + const auto pageHeight = renderer.getScreenHeight(); + renderer.drawCenteredText(READER_FONT_ID, pageHeight / 2 - 20, "Starting Hotspot...", true, BOLD); + renderer.displayBuffer(); } } void CrossPointWebServerActivity::renderServerRunning() const { const auto pageHeight = renderer.getScreenHeight(); - const auto height = renderer.getLineHeight(UI_FONT_ID); - const auto top = (pageHeight - height * 5) / 2; - renderer.drawCenteredText(READER_FONT_ID, top - 30, "File Transfer", true, BOLD); + // Use consistent line spacing + constexpr int LINE_SPACING = 28; // Space between lines - std::string ssidInfo = "Network: " + connectedSSID; - if (ssidInfo.length() > 28) { - ssidInfo.replace(25, ssidInfo.length() - 25, "..."); + renderer.drawCenteredText(READER_FONT_ID, 15, "File Transfer", true, BOLD); + + if (isApMode) { + // AP mode display - center the content block + const int startY = 55; + + renderer.drawCenteredText(UI_FONT_ID, startY, "Hotspot Mode", true, BOLD); + + std::string ssidInfo = "Network: " + connectedSSID; + renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING, ssidInfo.c_str(), true, REGULAR); + + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 2, "Connect your device to this WiFi network", + true, REGULAR); + + // Show primary URL (hostname) + std::string hostnameUrl = std::string("http://") + AP_HOSTNAME + ".local/"; + renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, BOLD); + + // Show IP address as fallback + std::string ipUrl = "or http://" + connectedIP + "/"; + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, ipUrl.c_str(), true, REGULAR); + + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "Open this URL in your browser", true, REGULAR); + } else { + // STA mode display (original behavior) + const int startY = 65; + + std::string ssidInfo = "Network: " + connectedSSID; + if (ssidInfo.length() > 28) { + ssidInfo.replace(25, ssidInfo.length() - 25, "..."); + } + renderer.drawCenteredText(UI_FONT_ID, startY, ssidInfo.c_str(), true, REGULAR); + + std::string ipInfo = "IP Address: " + connectedIP; + renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING, ipInfo.c_str(), true, REGULAR); + + // Show web server URL prominently + std::string webInfo = "http://" + connectedIP + "/"; + renderer.drawCenteredText(UI_FONT_ID, startY + LINE_SPACING * 2, webInfo.c_str(), true, BOLD); + + // Also show hostname URL + std::string hostnameUrl = std::string("or http://") + AP_HOSTNAME + ".local/"; + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, REGULAR); + + renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser", true, REGULAR); } - renderer.drawCenteredText(UI_FONT_ID, top + 10, ssidInfo.c_str(), true, REGULAR); - - std::string ipInfo = "IP Address: " + connectedIP; - renderer.drawCenteredText(UI_FONT_ID, top + 40, ipInfo.c_str(), true, REGULAR); - - // Show web server URL prominently - std::string webInfo = "http://" + connectedIP + "/"; - renderer.drawCenteredText(UI_FONT_ID, top + 70, webInfo.c_str(), true, BOLD); - - renderer.drawCenteredText(SMALL_FONT_ID, top + 110, "Open this URL in your browser", true, REGULAR); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press BACK to exit", true, REGULAR); } diff --git a/src/activities/network/CrossPointWebServerActivity.h b/src/activities/network/CrossPointWebServerActivity.h index 6889f6eb..038a0c4b 100644 --- a/src/activities/network/CrossPointWebServerActivity.h +++ b/src/activities/network/CrossPointWebServerActivity.h @@ -7,12 +7,15 @@ #include #include +#include "NetworkModeSelectionActivity.h" #include "activities/ActivityWithSubactivity.h" #include "network/CrossPointWebServer.h" // Web server activity states enum class WebServerActivityState { - WIFI_SELECTION, // WiFi selection subactivity is active + 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 }; @@ -20,8 +23,10 @@ enum class WebServerActivityState { /** * CrossPointWebServerActivity is the entry point for file transfer functionality. * It: - * - Immediately turns on WiFi and launches WifiSelectionActivity on enter - * - When WifiSelectionActivity completes successfully, starts the CrossPointWebServer + * - 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 */ @@ -29,15 +34,19 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; bool updateRequired = false; - WebServerActivityState state = WebServerActivityState::WIFI_SELECTION; + 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; + std::string connectedSSID; // For STA mode: network name, For AP mode: AP name // Performance monitoring unsigned long lastHandleClientTime = 0; @@ -47,7 +56,9 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity { void render() const; void renderServerRunning() const; + void onNetworkModeSelected(NetworkMode mode); void onWifiSelectionComplete(bool connected); + void startAccessPoint(); void startWebServer(); void stopWebServer(); diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp new file mode 100644 index 00000000..637d82d9 --- /dev/null +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -0,0 +1,128 @@ +#include "NetworkModeSelectionActivity.h" + +#include +#include + +#include "config.h" + +namespace { +constexpr int MENU_ITEM_COUNT = 2; +const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Join a Network", "Create Hotspot"}; +const char* MENU_DESCRIPTIONS[MENU_ITEM_COUNT] = {"Connect to an existing WiFi network", + "Create a WiFi network others can join"}; +} // namespace + +void NetworkModeSelectionActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void NetworkModeSelectionActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + // Reset selection + selectedIndex = 0; + + // Trigger first update + updateRequired = true; + + xTaskCreate(&NetworkModeSelectionActivity::taskTrampoline, "NetworkModeTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void NetworkModeSelectionActivity::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 NetworkModeSelectionActivity::loop() { + // Handle back button - cancel + if (inputManager.wasPressed(InputManager::BTN_BACK)) { + onCancel(); + return; + } + + // Handle confirm button - select current option + if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + const NetworkMode mode = (selectedIndex == 0) ? NetworkMode::JOIN_NETWORK : NetworkMode::CREATE_HOTSPOT; + onModeSelected(mode); + return; + } + + // Handle navigation + const bool prevPressed = + inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT); + const bool nextPressed = + inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_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 NetworkModeSelectionActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void NetworkModeSelectionActivity::render() const { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Draw header + renderer.drawCenteredText(READER_FONT_ID, 10, "File Transfer", true, BOLD); + + // Draw subtitle + renderer.drawCenteredText(UI_FONT_ID, 50, "How would you like to connect?", true, REGULAR); + + // 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_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 + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, "Press OK to select, BACK to cancel", true, REGULAR); + + renderer.displayBuffer(); +} diff --git a/src/activities/network/NetworkModeSelectionActivity.h b/src/activities/network/NetworkModeSelectionActivity.h new file mode 100644 index 00000000..90f4282b --- /dev/null +++ b/src/activities/network/NetworkModeSelectionActivity.h @@ -0,0 +1,41 @@ +#pragma once +#include +#include +#include + +#include + +#include "../Activity.h" + +// Enum for network mode selection +enum class NetworkMode { JOIN_NETWORK, CREATE_HOTSPOT }; + +/** + * NetworkModeSelectionActivity presents the user with a choice: + * - "Join a Network" - Connect to an existing WiFi network (STA mode) + * - "Create Hotspot" - Create an Access Point that others can connect to (AP mode) + * + * The onModeSelected callback is called with the user's choice. + * The onCancel callback is called if the user presses back. + */ +class NetworkModeSelectionActivity final : public Activity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + int selectedIndex = 0; + bool updateRequired = false; + const std::function onModeSelected; + const std::function onCancel; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + + public: + explicit NetworkModeSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, + const std::function& onModeSelected, + const std::function& onCancel) + : Activity("NetworkModeSelection", renderer, inputManager), onModeSelected(onModeSelected), onCancel(onCancel) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 10159aba..f14081f7 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -30,12 +30,22 @@ void CrossPointWebServer::begin() { return; } - if (WiFi.status() != WL_CONNECTED) { - Serial.printf("[%lu] [WEB] Cannot start webserver - WiFi not connected\n", millis()); + // 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] [WEB] Cannot start webserver - no valid network (mode=%d, status=%d)\n", millis(), wifiMode, + WiFi.status()); return; } + // Store AP mode flag for later use (e.g., in handleStatus) + apMode = isInApMode; + Serial.printf("[%lu] [WEB] [MEM] Free heap before begin: %d bytes\n", millis(), ESP.getFreeHeap()); + Serial.printf("[%lu] [WEB] Network mode: %s\n", millis(), apMode ? "AP" : "STA"); Serial.printf("[%lu] [WEB] Creating web server on port %d...\n", millis(), port); server.reset(new WebServer(port)); @@ -70,7 +80,9 @@ void CrossPointWebServer::begin() { running = true; Serial.printf("[%lu] [WEB] Web server started on port %d\n", millis(), port); - Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), WiFi.localIP().toString().c_str()); + // Show the correct IP based on network mode + const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); + Serial.printf("[%lu] [WEB] Access at http://%s/\n", millis(), ipAddr.c_str()); Serial.printf("[%lu] [WEB] [MEM] Free heap after server.begin(): %d bytes\n", millis(), ESP.getFreeHeap()); } @@ -141,10 +153,14 @@ void CrossPointWebServer::handleNotFound() const { } void CrossPointWebServer::handleStatus() const { + // Get correct IP based on AP vs STA mode + const String ipAddr = apMode ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); + String json = "{"; json += "\"version\":\"" + String(CROSSPOINT_VERSION) + "\","; - json += "\"ip\":\"" + WiFi.localIP().toString() + "\","; - json += "\"rssi\":" + String(WiFi.RSSI()) + ","; + json += "\"ip\":\"" + ipAddr + "\","; + json += "\"mode\":\"" + String(apMode ? "AP" : "STA") + "\","; + json += "\"rssi\":" + String(apMode ? 0 : WiFi.RSSI()) + ","; // RSSI not applicable in AP mode json += "\"freeHeap\":" + String(ESP.getFreeHeap()) + ","; json += "\"uptime\":" + String(millis() / 1000); json += "}"; diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 327897fb..1be07b4a 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -35,6 +35,7 @@ class CrossPointWebServer { private: std::unique_ptr server = nullptr; bool running = false; + bool apMode = false; // true when running in AP mode, false for STA mode uint16_t port = 80; // File scanning