#include "CrossPointWebServerActivity.h" #include #include #include #include #include #include #include #include #include "MappedInputManager.h" #include "NetworkModeSelectionActivity.h" #include "WifiSelectionActivity.h" #include "activities/network/CalibreConnectActivity.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; // mDNS service discovery - advertises the device for network scanning // Controlled by build flags in platformio.ini: // MDNS_SERVICE_DISCOVERY - enables service advertisement (required for Android NSD) // MDNS_SERVICE_TXT_RECORDS - enables TXT record metadata (device type, ports) void setupMdnsServiceDiscovery(uint16_t httpPort, uint16_t wsPort) { #if MDNS_SERVICE_DISCOVERY // Advertise HTTP service for network discovery (required for Android NSD) MDNS.addService("http", "tcp", httpPort); Serial.printf("[%lu] [WEBACT] mDNS service advertised: _http._tcp port %d\n", millis(), httpPort); #if MDNS_SERVICE_TXT_RECORDS // Add TXT records with device info for enhanced discovery MDNS.addServiceTxt("http", "tcp", "device", "crosspoint"); MDNS.addServiceTxt("http", "tcp", "wsPort", String(wsPort).c_str()); Serial.printf("[%lu] [WEBACT] mDNS TXT records added (device=crosspoint, wsPort=%d)\n", millis(), wsPort); #else (void)wsPort; #endif #else (void)httpPort; (void)wsPort; Serial.printf("[%lu] [WEBACT] mDNS service discovery disabled\n", millis()); #endif } } // namespace void CrossPointWebServerActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); } void CrossPointWebServerActivity::onEnter() { ActivityWithSubactivity::onEnter(); Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onEnter: %d bytes\n", millis(), ESP.getFreeHeap()); renderingMutex = xSemaphoreCreateMutex(); // Reset state state = WebServerActivityState::MODE_SELECTION; networkMode = NetworkMode::JOIN_NETWORK; isApMode = false; connectedIP.clear(); connectedSSID.clear(); lastHandleClientTime = 0; currentScreen = FileTransferScreen::COMPANION_APP_LIBRARY; lastStatsRefresh = 0; updateRequired = true; xTaskCreate(&CrossPointWebServerActivity::taskTrampoline, "WebServerActivityTask", 6144, // Stack size (increased: QR code + string ops need ~4KB) this, // Parameters 1, // Priority &displayTaskHandle // Task handle ); // Launch network mode selection subactivity Serial.printf("[%lu] [WEBACT] Launching NetworkModeSelectionActivity...\n", millis()); enterNewActivity(new NetworkModeSelectionActivity( renderer, mappedInput, [this](const NetworkMode mode) { onNetworkModeSelected(mode); }, [this]() { onGoBack(); } // Cancel goes back to home )); } void CrossPointWebServerActivity::onExit() { ActivityWithSubactivity::onExit(); Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit start: %d, Largest: %d\n", millis(), ESP.getFreeHeap(), ESP.getMaxAllocHeap()); state = WebServerActivityState::SHUTTING_DOWN; // Stop the web server first (before disconnecting WiFi) stopWebServer(); Serial.printf("[%lu] [WEBACT] [MEM] After stopWebServer: %d bytes\n", millis(), ESP.getFreeHeap()); // Stop mDNS MDNS.end(); Serial.printf("[%lu] [WEBACT] [MEM] After MDNS.end: %d bytes\n", millis(), ESP.getFreeHeap()); // Stop DNS server if running (AP mode) if (dnsServer) { Serial.printf("[%lu] [WEBACT] Stopping DNS server...\n", millis()); dnsServer->stop(); delete dnsServer; dnsServer = nullptr; Serial.printf("[%lu] [WEBACT] [MEM] After DNS server cleanup: %d bytes\n", millis(), ESP.getFreeHeap()); } // Brief wait for LWIP stack to flush pending packets delay(50); // Disconnect WiFi gracefully if (isApMode) { Serial.printf("[%lu] [WEBACT] Stopping WiFi AP...\n", millis()); WiFi.softAPdisconnect(true); } else { Serial.printf("[%lu] [WEBACT] Disconnecting WiFi...\n", millis()); WiFi.disconnect(false); // false = don't erase credentials, just send disconnect frame } delay(100); // Allow disconnect frame to be sent and event callbacks to complete Serial.printf("[%lu] [WEBACT] [MEM] After WiFi disconnect: %d bytes\n", millis(), ESP.getFreeHeap()); Serial.printf("[%lu] [WEBACT] Setting WiFi mode OFF...\n", millis()); WiFi.mode(WIFI_OFF); delay(50); Serial.printf("[%lu] [WEBACT] [MEM] After WiFi.mode(OFF): %d bytes\n", millis(), ESP.getFreeHeap()); // Aggressive WiFi cleanup: stop the WiFi driver to recover more memory // WiFi is on-demand for File Transfer only, so full cleanup is safe Serial.printf("[%lu] [WEBACT] Stopping WiFi driver (esp_wifi_stop)...\n", millis()); esp_wifi_stop(); delay(100); // Allow LWIP stack to fully release Serial.printf("[%lu] [WEBACT] [MEM] After esp_wifi_stop: %d, Largest: %d\n", millis(), ESP.getFreeHeap(), ESP.getMaxAllocHeap()); // Acquire mutex before deleting task Serial.printf("[%lu] [WEBACT] Acquiring rendering mutex before task deletion...\n", millis()); xSemaphoreTake(renderingMutex, portMAX_DELAY); // Delete the display task Serial.printf("[%lu] [WEBACT] Deleting display task...\n", millis()); if (displayTaskHandle) { // Log stack high-water mark before deleting task (stack size: 6144 bytes) LOG_STACK_WATERMARK("CrossPointWebServerActivity", displayTaskHandle); vTaskDelete(displayTaskHandle); displayTaskHandle = nullptr; // Allow idle task to free the task stack vTaskDelay(10 / portTICK_PERIOD_MS); Serial.printf("[%lu] [WEBACT] [MEM] After task delete + yield: %d bytes\n", millis(), ESP.getFreeHeap()); } // Delete the mutex Serial.printf("[%lu] [WEBACT] Deleting mutex...\n", millis()); vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; Serial.printf("[%lu] [WEBACT] [MEM] Free heap at onExit end: %d, Largest: %d, MinFree: %d\n", millis(), ESP.getFreeHeap(), ESP.getMaxAllocHeap(), ESP.getMinFreeHeap()); } 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"; } Serial.printf("[%lu] [WEBACT] Network mode selected: %s\n", millis(), 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 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, mappedInput, [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); if (connected) { // Get connection info before exiting subactivity connectedIP = static_cast(subActivity.get())->getConnectedIP(); connectedSSID = WiFi.SSID().c_str(); isApMode = false; exitActivity(); // Start mDNS for hostname resolution if (MDNS.begin(AP_HOSTNAME)) { Serial.printf("[%lu] [WEBACT] mDNS started: http://%s.local/\n", millis(), AP_HOSTNAME); setupMdnsServiceDiscovery(80, 81); } // 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() { 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); setupMdnsServiceDiscovery(80, 81); } 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()); // Create the web server instance webServer.reset(new CrossPointWebServer()); webServer->begin(); if (webServer->isRunning()) { state = WebServerActivityState::SERVER_RUNNING; Serial.printf("[%lu] [WEBACT] Web 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()); } else { Serial.printf("[%lu] [WEBACT] ERROR: Failed to start web server!\n", millis()); webServer.reset(); // Go back on error 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()); } 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) { Serial.printf("[%lu] [WEBACT] WiFi disconnected! Status: %d\n", millis(), wifiStatus); // Show error and exit gracefully state = WebServerActivityState::SHUTTING_DOWN; updateRequired = true; return; } // Log weak signal warnings const int rssi = WiFi.RSSI(); if (rssi < -75) { Serial.printf("[%lu] [WEBACT] Warning: Weak WiFi signal: %d dBm\n", millis(), 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) { Serial.printf("[%lu] [WEBACT] WARNING: %lu ms gap since last handleClient\n", millis(), 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(); // Check for exit button inside loop for responsiveness if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onGoBack(); return; } } } lastHandleClientTime = millis(); } // Handle screen switching with front arrow buttons (Left or Right cycles forward) // Order: COMPANION_APP_LIBRARY -> WEB_BROWSER -> COMPANION_APP -> back to COMPANION_APP_LIBRARY if (mappedInput.wasPressed(MappedInputManager::Button::Left) || mappedInput.wasPressed(MappedInputManager::Button::Right)) { switch (currentScreen) { case FileTransferScreen::COMPANION_APP_LIBRARY: currentScreen = FileTransferScreen::WEB_BROWSER; break; case FileTransferScreen::WEB_BROWSER: currentScreen = FileTransferScreen::COMPANION_APP; break; case FileTransferScreen::COMPANION_APP: currentScreen = FileTransferScreen::COMPANION_APP_LIBRARY; break; } updateRequired = true; Serial.printf("[%lu] [WEBACT] Switched to screen: %d\n", millis(), static_cast(currentScreen)); } // Refresh stats every 30 seconds if (millis() - lastStatsRefresh >= 30000) { lastStatsRefresh = millis(); updateRequired = true; Serial.printf("[%lu] [WEBACT] Stats refresh triggered\n", millis()); } // Handle exit on Back button (also check outside loop) if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onGoBack(); return; } } } void CrossPointWebServerActivity::displayTaskLoop() { while (true) { if (updateRequired) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); render(); xSemaphoreGive(renderingMutex); } vTaskDelay(10 / portTICK_PERIOD_MS); } } void CrossPointWebServerActivity::render() const { // Only render our own UI when server is running // 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(); const int bezelTop = renderer.getBezelOffsetTop(); const int bezelBottom = renderer.getBezelOffsetBottom(); const int centerY = (pageHeight - bezelTop - bezelBottom) / 2 + bezelTop; renderer.drawCenteredText(UI_12_FONT_ID, centerY - 20, "Starting Hotspot...", true, EpdFontFamily::BOLD); renderer.displayBuffer(); } } // Draw QR code at specified position with configurable pixel size per module // Returns the size of the QR code in pixels (width = height = size * pixelsPerModule) int drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std::string& data, const uint8_t pixelsPerModule = 7) { QRCode qrcode; uint8_t qrcodeBytes[qrcode_getBufferSize(4)]; Serial.printf("[%lu] [WEBACT] QR Code (%lu): %s\n", millis(), data.length(), data.c_str()); qrcode_initText(&qrcode, qrcodeBytes, 4, ECC_LOW, data.c_str()); 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 + pixelsPerModule * cx, y + pixelsPerModule * cy, pixelsPerModule, pixelsPerModule, true); } } } return qrcode.size * pixelsPerModule; } // Helper to format bytes into human-readable sizes std::string formatBytes(size_t bytes) { if (bytes < 1024) { return std::to_string(bytes) + " B"; } else if (bytes < 1024 * 1024) { char buf[32]; snprintf(buf, sizeof(buf), "%.1f KB", bytes / 1024.0); return buf; } else { char buf[32]; snprintf(buf, sizeof(buf), "%.1f MB", bytes / (1024.0 * 1024.0)); return buf; } } // Helper to format uptime in human-readable format std::string formatUptime(unsigned long seconds) { if (seconds < 60) { return std::to_string(seconds) + "s"; } else if (seconds < 3600) { unsigned long mins = seconds / 60; unsigned long secs = seconds % 60; return std::to_string(mins) + "m " + std::to_string(secs) + "s"; } else { unsigned long hours = seconds / 3600; unsigned long mins = (seconds % 3600) / 60; return std::to_string(hours) + "h " + std::to_string(mins) + "m"; } } std::string CrossPointWebServerActivity::getCompanionAppUrl() const { // Generate deep link URL for companion Android app - Device/Files tab // Format: crosspoint://files?host=&port=&wsPort= std::string url = "crosspoint://files?host=" + connectedIP; if (webServer) { url += "&port=" + std::to_string(webServer->getPort()); url += "&wsPort=" + std::to_string(webServer->getWsPort()); } return url; } std::string CrossPointWebServerActivity::getCompanionAppLibraryUrl() const { // Generate deep link URL for companion Android app - Library tab // Format: crosspoint://library?host=&port=&wsPort= std::string url = "crosspoint://library?host=" + connectedIP; if (webServer) { url += "&port=" + std::to_string(webServer->getPort()); url += "&wsPort=" + std::to_string(webServer->getWsPort()); } return url; } void CrossPointWebServerActivity::renderServerRunning() const { // Switch to landscape orientation for the QR code screens (800x480) const auto originalOrientation = renderer.getOrientation(); renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise); // Dispatch to the appropriate screen switch (currentScreen) { case FileTransferScreen::WEB_BROWSER: renderWebBrowserScreen(); break; case FileTransferScreen::COMPANION_APP: renderCompanionAppScreen(); break; case FileTransferScreen::COMPANION_APP_LIBRARY: renderCompanionAppLibraryScreen(); break; } // Render stats bar at the bottom (above button hints) renderStats(); // Restore orientation before drawing button hints (they handle their own orientation internally) renderer.setOrientation(originalOrientation); // Draw button hints - Left/Right cycle screens, Back exits const auto labels = mappedInput.mapLabels("« Exit", "", "", "Next »"); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void CrossPointWebServerActivity::renderStats() const { if (!webServer) return; // In landscape (800x480), position stats near bottom but above button area const int screenHeight = renderer.getScreenHeight(); // 480 in landscape const int statsY = screenHeight - 30; // Get stats from web server const unsigned long uptime = webServer->getServerUptime(); const size_t bytesUp = webServer->getTotalBytesUploaded(); const size_t bytesDown = webServer->getTotalBytesDownloaded(); const size_t filesUp = webServer->getTotalFilesUploaded(); const size_t filesDown = webServer->getTotalFilesDownloaded(); // Format stats string std::string statsLine = "Up: " + formatUptime(uptime); statsLine += " | Recv: " + formatBytes(bytesUp); if (filesUp > 0) { statsLine += " (" + std::to_string(filesUp) + ")"; } statsLine += " | Sent: " + formatBytes(bytesDown); if (filesDown > 0) { statsLine += " (" + std::to_string(filesDown) + ")"; } renderer.drawCenteredText(UI_10_FONT_ID, statsY, statsLine.c_str()); } void CrossPointWebServerActivity::renderWebBrowserScreen() const { // Landscape layout (800x480): QR on left, text on right constexpr int QR_X = 15; constexpr int QR_Y = 15; constexpr int QR_PX = 7; // pixels per QR module constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px constexpr int TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin constexpr int LINE_SPACING = 32; // Draw title on right side renderer.drawText(NOTOSANS_14_FONT_ID, TEXT_X, QR_Y, "File Transfer - Web Browser", true, EpdFontFamily::BOLD); int textY = QR_Y + LINE_SPACING + 10; if (isApMode) { // AP mode: Show WiFi QR code on left, connection info on right const std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;"; drawQRCode(renderer, QR_X, QR_Y, wifiConfig, QR_PX); std::string ssidInfo = "Network: " + connectedSSID; renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str()); textY += LINE_SPACING; renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, "Scan QR code to connect to WiFi,"); textY += LINE_SPACING - 4; renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, "then open the URL in your browser."); textY += LINE_SPACING + 8; renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, "Browser URL:", true, EpdFontFamily::BOLD); textY += LINE_SPACING - 4; std::string webUrl = "http://" + connectedIP + "/"; renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str(), true, EpdFontFamily::BOLD); textY += LINE_SPACING - 4; std::string hostnameUrl = std::string("or http://") + AP_HOSTNAME + ".local/"; renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, hostnameUrl.c_str()); } else { // STA mode: Show URL QR code on left, connection info on right std::string webUrl = "http://" + connectedIP + "/"; drawQRCode(renderer, QR_X, QR_Y, webUrl, QR_PX); std::string ssidInfo = "Network: " + connectedSSID; if (ssidInfo.length() > 35) { ssidInfo = ssidInfo.substr(0, 32) + "..."; } renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str()); textY += LINE_SPACING; std::string ipInfo = "IP Address: " + connectedIP; renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ipInfo.c_str()); textY += LINE_SPACING + 8; renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str(), true, EpdFontFamily::BOLD); textY += LINE_SPACING - 4; std::string hostnameUrl = std::string("or http://") + AP_HOSTNAME + ".local/"; renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, hostnameUrl.c_str()); textY += LINE_SPACING + 4; renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, "Open this URL in your browser"); textY += LINE_SPACING - 4; renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, "or scan QR code with your phone:"); } } void CrossPointWebServerActivity::renderCompanionAppScreen() const { // Landscape layout (800x480): QR on left, text on right constexpr int QR_X = 15; constexpr int QR_Y = 15; constexpr int QR_PX = 7; // pixels per QR module constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px constexpr int TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin constexpr int LINE_SPACING = 32; // Draw title on right side renderer.drawText(NOTOSANS_14_FONT_ID, TEXT_X, QR_Y, "Companion App - Files", true, EpdFontFamily::BOLD); int textY = QR_Y + LINE_SPACING + 10; // Show network info std::string ssidInfo = "Network: " + connectedSSID; if (ssidInfo.length() > 35) { ssidInfo = ssidInfo.substr(0, 32) + "..."; } renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str()); textY += LINE_SPACING; // Show scan instructions if (isApMode) { renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, "Connect to this WiFi first, then scan"); textY += LINE_SPACING - 4; renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, "QR code with the Companion app."); } else { renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, "Scan the QR code with a phone that"); textY += LINE_SPACING - 4; renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, "has the CrossPoint Companion app."); } textY += LINE_SPACING + 8; // Show alternative browser access renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, "Alternative Browser Access:", true, EpdFontFamily::BOLD); textY += LINE_SPACING - 4; std::string webUrl = "http://" + connectedIP + "/files"; renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str()); // Draw QR code on left const std::string appUrl = getCompanionAppUrl(); drawQRCode(renderer, QR_X, QR_Y, appUrl, QR_PX); // Show deep link URL below QR code const int urlY = QR_Y + QR_SIZE + 10; renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD); } void CrossPointWebServerActivity::renderCompanionAppLibraryScreen() const { // Landscape layout (800x480): QR on left, text on right constexpr int QR_X = 15; constexpr int QR_Y = 15; constexpr int QR_PX = 7; // pixels per QR module constexpr int QR_SIZE = 33 * QR_PX; // QR version 4 = 33 modules = 231px constexpr int TEXT_X = QR_X + QR_SIZE + 35; // Text starts after QR + margin constexpr int LINE_SPACING = 32; // Draw title on right side renderer.drawText(NOTOSANS_14_FONT_ID, TEXT_X, QR_Y, "Companion App - Library", true, EpdFontFamily::BOLD); int textY = QR_Y + LINE_SPACING + 10; // Show network info std::string ssidInfo = "Network: " + connectedSSID; if (ssidInfo.length() > 35) { ssidInfo = ssidInfo.substr(0, 32) + "..."; } renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str()); textY += LINE_SPACING; // Show scan instructions if (isApMode) { renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, "Connect to this WiFi first, then scan"); textY += LINE_SPACING - 4; renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, "QR code with the Companion app."); } else { renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, "Scan the QR code with a phone that"); textY += LINE_SPACING - 4; renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, "has the CrossPoint Companion app."); } textY += LINE_SPACING + 8; // Show alternative browser access renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, "Alternative Browser Access:", true, EpdFontFamily::BOLD); textY += LINE_SPACING - 4; std::string webUrl = "http://" + connectedIP + "/"; renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str()); // Draw QR code on left const std::string appUrl = getCompanionAppLibraryUrl(); drawQRCode(renderer, QR_X, QR_Y, appUrl, QR_PX); // Show deep link URL below QR code const int urlY = QR_Y + QR_SIZE + 10; renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD); }