#include "CrossPointWebServerActivity.h" #include #include #include #include #include #include #include #include "MappedInputManager.h" #include "NetworkModeSelectionActivity.h" #include "WifiSelectionActivity.h" #include "activities/network/CalibreConnectActivity.h" #include "components/UITheme.h" #include "fontIds.h" #include "util/QrUtils.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; constexpr int QR_CODE_WIDTH = 198; constexpr int QR_CODE_HEIGHT = 198; // DNS server for captive portal (redirects all DNS queries to our IP) DNSServer* dnsServer = nullptr; constexpr uint16_t DNS_PORT = 53; } // namespace void CrossPointWebServerActivity::onEnter() { ActivityWithSubactivity::onEnter(); LOG_DBG("WEBACT", "Free heap at onEnter: %d bytes", ESP.getFreeHeap()); // Reset state state = WebServerActivityState::MODE_SELECTION; networkMode = NetworkMode::JOIN_NETWORK; isApMode = false; connectedIP.clear(); connectedSSID.clear(); lastHandleClientTime = 0; requestUpdate(); // Launch network mode selection subactivity LOG_DBG("WEBACT", "Launching NetworkModeSelectionActivity..."); enterNewActivity(new NetworkModeSelectionActivity( renderer, mappedInput, [this](const NetworkMode mode) { onNetworkModeSelected(mode); }, [this]() { onGoBack(); } // Cancel goes back to home )); } void CrossPointWebServerActivity::onExit() { ActivityWithSubactivity::onExit(); LOG_DBG("WEBACT", "Free heap at onExit start: %d bytes", ESP.getFreeHeap()); state = WebServerActivityState::SHUTTING_DOWN; // Stop the web server first (before disconnecting WiFi) stopWebServer(); // Stop mDNS MDNS.end(); // Stop DNS server if running (AP mode) if (dnsServer) { LOG_DBG("WEBACT", "Stopping DNS server..."); dnsServer->stop(); delete dnsServer; dnsServer = nullptr; } // Brief wait for LWIP stack to flush pending packets delay(50); // Disconnect WiFi gracefully if (isApMode) { LOG_DBG("WEBACT", "Stopping WiFi AP..."); WiFi.softAPdisconnect(true); } else { LOG_DBG("WEBACT", "Disconnecting WiFi (graceful)..."); WiFi.disconnect(false); // false = don't erase credentials, send disconnect frame } delay(30); // Allow disconnect frame to be sent LOG_DBG("WEBACT", "Setting WiFi mode OFF..."); WiFi.mode(WIFI_OFF); delay(30); // Allow WiFi hardware to power down LOG_DBG("WEBACT", "Free heap at onExit end: %d bytes", ESP.getFreeHeap()); } void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode) { const char* modeName = "Join Network"; if (mode == NetworkMode::CONNECT_CALIBRE) { modeName = "Connect to Calibre"; } else if (mode == NetworkMode::CREATE_HOTSPOT) { modeName = "Create Hotspot"; } LOG_DBG("WEBACT", "Network mode selected: %s", modeName); networkMode = mode; isApMode = (mode == NetworkMode::CREATE_HOTSPOT); // Exit mode selection subactivity exitActivity(); if (mode == NetworkMode::CONNECT_CALIBRE) { exitActivity(); enterNewActivity(new CalibreConnectActivity(renderer, mappedInput, [this] { exitActivity(); state = WebServerActivityState::MODE_SELECTION; enterNewActivity(new NetworkModeSelectionActivity( renderer, mappedInput, [this](const NetworkMode nextMode) { onNetworkModeSelected(nextMode); }, [this]() { onGoBack(); })); })); return; } if (mode == NetworkMode::JOIN_NETWORK) { // STA mode - launch WiFi selection LOG_DBG("WEBACT", "Turning on WiFi (STA mode)..."); WiFi.mode(WIFI_STA); state = WebServerActivityState::WIFI_SELECTION; LOG_DBG("WEBACT", "Launching WifiSelectionActivity..."); enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, [this](const bool connected) { onWifiSelectionComplete(connected); })); } else { // AP mode - start access point state = WebServerActivityState::AP_STARTING; requestUpdate(); startAccessPoint(); } } void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected) { LOG_DBG("WEBACT", "WifiSelectionActivity completed, connected=%d", connected); if (connected) { // Get connection info before exiting subactivity connectedIP = static_cast(subActivity.get())->getConnectedIP(); connectedSSID = WiFi.SSID().c_str(); isApMode = false; exitActivity(); // Start mDNS for hostname resolution if (MDNS.begin(AP_HOSTNAME)) { LOG_DBG("WEBACT", "mDNS started: http://%s.local/", AP_HOSTNAME); } // Start the web server startWebServer(); } else { // User cancelled - go back to mode selection exitActivity(); state = WebServerActivityState::MODE_SELECTION; enterNewActivity(new NetworkModeSelectionActivity( renderer, mappedInput, [this](const NetworkMode mode) { onNetworkModeSelected(mode); }, [this]() { onGoBack(); })); } } void CrossPointWebServerActivity::startAccessPoint() { LOG_DBG("WEBACT", "Starting Access Point mode..."); LOG_DBG("WEBACT", "Free heap before AP start: %d bytes", 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) { LOG_ERR("WEBACT", "ERROR: Failed to start Access Point!"); 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; LOG_DBG("WEBACT", "Access Point started!"); LOG_DBG("WEBACT", "SSID: %s", AP_SSID); LOG_DBG("WEBACT", "IP: %s", connectedIP.c_str()); // Start mDNS for hostname resolution if (MDNS.begin(AP_HOSTNAME)) { LOG_DBG("WEBACT", "mDNS started: http://%s.local/", AP_HOSTNAME); } else { LOG_DBG("WEBACT", "WARNING: mDNS failed to start"); } // 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); LOG_DBG("WEBACT", "DNS server started for captive portal"); LOG_DBG("WEBACT", "Free heap after AP start: %d bytes", ESP.getFreeHeap()); // Start the web server startWebServer(); } void CrossPointWebServerActivity::startWebServer() { LOG_DBG("WEBACT", "Starting web server..."); // Create the web server instance webServer.reset(new CrossPointWebServer()); webServer->begin(); if (webServer->isRunning()) { state = WebServerActivityState::SERVER_RUNNING; LOG_DBG("WEBACT", "Web server started successfully"); // 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. { RenderLock lock(*this); render(std::move(lock)); } LOG_DBG("WEBACT", "Rendered File Transfer screen"); } else { LOG_ERR("WEBACT", "ERROR: Failed to start web server!"); webServer.reset(); // Go back on error onGoBack(); } } void CrossPointWebServerActivity::stopWebServer() { if (webServer && webServer->isRunning()) { LOG_DBG("WEBACT", "Stopping web server..."); webServer->stop(); LOG_DBG("WEBACT", "Web server stopped"); } webServer.reset(); } void CrossPointWebServerActivity::loop() { if (subActivity) { // Forward loop to subactivity subActivity->loop(); return; } // Handle different states if (state == WebServerActivityState::SERVER_RUNNING) { // Handle DNS requests for captive portal (AP mode only) if (isApMode && dnsServer) { dnsServer->processNextRequest(); } // STA mode: Monitor WiFi connection health if (!isApMode && webServer && webServer->isRunning()) { static unsigned long lastWifiCheck = 0; if (millis() - lastWifiCheck > 2000) { // Check every 2 seconds lastWifiCheck = millis(); const wl_status_t wifiStatus = WiFi.status(); if (wifiStatus != WL_CONNECTED) { LOG_DBG("WEBACT", "WiFi disconnected! Status: %d", wifiStatus); // Show error and exit gracefully state = WebServerActivityState::SHUTTING_DOWN; requestUpdate(); return; } // Log weak signal warnings const int rssi = WiFi.RSSI(); if (rssi < -75) { LOG_DBG("WEBACT", "Warning: Weak WiFi signal: %d dBm", rssi); } } } // Handle web server requests - maximize throughput with watchdog safety if (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) { LOG_DBG("WEBACT", "WARNING: %lu ms gap since last handleClient", timeSinceLastHandleClient); } // Reset watchdog BEFORE processing - HTTP header parsing can be slow esp_task_wdt_reset(); // Process HTTP requests in tight loop for maximum throughput // More iterations = more data processed per main loop cycle constexpr int MAX_ITERATIONS = 500; for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) { webServer->handleClient(); // Reset watchdog every 32 iterations if ((i & 0x1F) == 0x1F) { esp_task_wdt_reset(); } // Yield and check for exit button every 64 iterations if ((i & 0x3F) == 0x3F) { yield(); // Force trigger an update of which buttons are being pressed so be have accurate state // for back button checking mappedInput.update(); // Check for exit button inside loop for responsiveness if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onGoBack(); return; } } } lastHandleClientTime = millis(); } // Handle exit on Back button (also check outside loop) if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onGoBack(); return; } } } void CrossPointWebServerActivity::render(Activity::RenderLock&&) { // Only render our own UI when server is running // Subactivities handle their own rendering if (state == WebServerActivityState::SERVER_RUNNING || state == WebServerActivityState::AP_STARTING) { renderer.clearScreen(); const auto& metrics = UITheme::getInstance().getMetrics(); const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, isApMode ? tr(STR_HOTSPOT_MODE) : tr(STR_FILE_TRANSFER), nullptr); if (state == WebServerActivityState::SERVER_RUNNING) { GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, connectedSSID.c_str()); renderServerRunning(); } else { const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto top = (pageHeight - height) / 2; renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_STARTING_HOTSPOT)); } renderer.displayBuffer(); } } void CrossPointWebServerActivity::renderServerRunning() const { const auto& metrics = UITheme::getInstance().getMetrics(); const auto pageWidth = renderer.getScreenWidth(); GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, isApMode ? tr(STR_HOTSPOT_MODE) : tr(STR_FILE_TRANSFER), nullptr); GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, connectedSSID.c_str()); int startY = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing * 2; int height10 = renderer.getLineHeight(UI_10_FONT_ID); if (isApMode) { // AP mode display renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, startY, tr(STR_CONNECT_WIFI_HINT), true, EpdFontFamily::BOLD); startY += height10 + metrics.verticalSpacing * 2; // Show QR code for Wifi const std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;"; const Rect qrBoundsWifi(metrics.contentSidePadding, startY, QR_CODE_WIDTH, QR_CODE_HEIGHT); QrUtils::drawQrCode(renderer, qrBoundsWifi, wifiConfig); // Show network name renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding + QR_CODE_WIDTH + metrics.verticalSpacing, startY + 80, connectedSSID.c_str()); startY += QR_CODE_HEIGHT + 2 * metrics.verticalSpacing; // Show primary URL (hostname) renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, startY, tr(STR_OPEN_URL_HINT), true, EpdFontFamily::BOLD); startY += height10 + metrics.verticalSpacing * 2; std::string hostnameUrl = std::string("http://") + AP_HOSTNAME + ".local/"; std::string ipUrl = tr(STR_OR_HTTP_PREFIX) + connectedIP + "/"; // Show QR code for URL const Rect qrBoundsUrl(metrics.contentSidePadding, startY, QR_CODE_WIDTH, QR_CODE_HEIGHT); QrUtils::drawQrCode(renderer, qrBoundsUrl, hostnameUrl); // Show IP address as fallback renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding + QR_CODE_WIDTH + metrics.verticalSpacing, startY + 80, hostnameUrl.c_str()); renderer.drawText(SMALL_FONT_ID, metrics.contentSidePadding + QR_CODE_WIDTH + metrics.verticalSpacing, startY + 100, ipUrl.c_str()); } else { startY += metrics.verticalSpacing * 2; // STA mode display (original behavior) // std::string ipInfo = "IP Address: " + connectedIP; renderer.drawCenteredText(UI_10_FONT_ID, startY, tr(STR_OPEN_URL_HINT), true, EpdFontFamily::BOLD); startY += height10; renderer.drawCenteredText(UI_10_FONT_ID, startY, tr(STR_SCAN_QR_HINT), true, EpdFontFamily::BOLD); startY += height10 + metrics.verticalSpacing * 2; // Show QR code for URL std::string webInfo = "http://" + connectedIP + "/"; const Rect qrBounds((pageWidth - QR_CODE_WIDTH) / 2, startY, QR_CODE_WIDTH, QR_CODE_HEIGHT); QrUtils::drawQrCode(renderer, qrBounds, webInfo); startY += QR_CODE_HEIGHT + metrics.verticalSpacing * 2; // Show web server URL prominently renderer.drawCenteredText(UI_10_FONT_ID, startY, webInfo.c_str(), true); startY += height10 + 5; // Also show hostname URL std::string hostnameUrl = std::string(tr(STR_OR_HTTP_PREFIX)) + AP_HOSTNAME + ".local/"; renderer.drawCenteredText(SMALL_FONT_ID, startY, hostnameUrl.c_str(), true); } const auto labels = mappedInput.mapLabels(tr(STR_EXIT), "", "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); }