diff --git a/embedded/libs/esp-web-server/src/esp_web_server.cpp b/embedded/libs/esp-web-server/src/esp_web_server.cpp index cddbeb25..26ba17df 100644 --- a/embedded/libs/esp-web-server/src/esp_web_server.cpp +++ b/embedded/libs/esp-web-server/src/esp_web_server.cpp @@ -228,6 +228,9 @@ void ESPWebServer::handleRoot() { if (status.connected) { el.className = 'status connected'; el.innerHTML = 'Connected to ' + status.ssid + '
IP: ' + status.ip + ' | Signal: ' + status.rssi + ' dBm'; + } else if (status.ap_mode) { + el.className = 'status disconnected'; + el.innerHTML = 'Access Point Mode
IP: ' + status.ip + '
Connect to a WiFi network below'; } else { el.className = 'status disconnected'; el.textContent = 'Not connected'; @@ -453,6 +456,11 @@ void ESPWebServer::handleWiFiConnect() { const char* ssid = doc["ssid"]; const char* password = doc["password"] | ""; + // Stop AP mode if running before connecting to a new network + if (WiFiMgr.isAPMode()) { + WiFiMgr.stopAP(); + } + WiFiMgr.connect(ssid, password); sendJson(200, "{\"success\":true,\"message\":\"Connecting...\"}"); @@ -463,9 +471,10 @@ void ESPWebServer::handleWiFiStatus() { JsonDocument doc; doc["connected"] = WiFiMgr.isConnected(); - doc["ssid"] = WiFiMgr.getSSID(); - doc["ip"] = WiFiMgr.getIP(); - doc["rssi"] = WiFiMgr.getRSSI(); + doc["ap_mode"] = WiFiMgr.isAPMode(); + doc["ssid"] = WiFiMgr.isAPMode() ? "" : WiFiMgr.getSSID(); + doc["ip"] = WiFiMgr.isAPMode() ? WiFiMgr.getAPIP() : WiFiMgr.getIP(); + doc["rssi"] = WiFiMgr.isAPMode() ? 0 : WiFiMgr.getRSSI(); sendJson(200, doc); } diff --git a/embedded/libs/lilygo-display/src/lilygo_display.cpp b/embedded/libs/lilygo-display/src/lilygo_display.cpp index 85bb71b1..4a11936e 100644 --- a/embedded/libs/lilygo-display/src/lilygo_display.cpp +++ b/embedded/libs/lilygo-display/src/lilygo_display.cpp @@ -198,6 +198,69 @@ void LilyGoDisplay::showConfigPortal(const char* apName, const char* ip) { _display.setTextDatum(lgfx::top_left); } +void LilyGoDisplay::showSetupScreen(const char* apName) { + _display.fillScreen(COLOR_BACKGROUND); + + // Header + _display.setFont(&fonts::FreeSansBold9pt7b); + _display.setTextColor(COLOR_ACCENT); + _display.setTextDatum(lgfx::top_center); + _display.drawString("WiFi Setup", SCREEN_WIDTH / 2, 8); + + // Step 1: Connect to WiFi AP + _display.setFont(&fonts::Font2); + _display.setTextColor(COLOR_TEXT); + _display.drawString("1. Connect to WiFi:", SCREEN_WIDTH / 2, 38); + + _display.setFont(&fonts::FreeSansBold9pt7b); + _display.setTextColor(COLOR_STATUS_OK); + _display.drawString(apName, SCREEN_WIDTH / 2, 58); + + // QR Code section - generate QR for http://192.168.4.1 + const char* configUrl = "http://192.168.4.1"; + QRCode qrCode; + qrcode_initText(&qrCode, _qrCodeData, QR_VERSION, ECC_LOW, configUrl); + + // Calculate QR code size and position + int qrSize = qrCode.size; + int pixelSize = 100 / qrSize; // Target ~100px QR code + if (pixelSize < 1) pixelSize = 1; + + int actualQrSize = pixelSize * qrSize; + int qrX = (SCREEN_WIDTH - actualQrSize) / 2; + int qrY = 90; + + // Draw white background for QR code + _display.fillRect(qrX - 4, qrY - 4, actualQrSize + 8, actualQrSize + 8, COLOR_QR_BG); + + // Draw QR code modules + for (uint8_t y = 0; y < qrSize; y++) { + for (uint8_t x = 0; x < qrSize; x++) { + if (qrcode_getModule(&qrCode, x, y)) { + _display.fillRect(qrX + x * pixelSize, qrY + y * pixelSize, pixelSize, pixelSize, COLOR_QR_FG); + } + } + } + + // Step 2: Instructions below QR code + int instructionY = qrY + actualQrSize + 16; + + _display.setFont(&fonts::Font2); + _display.setTextColor(COLOR_TEXT); + _display.drawString("2. Scan QR code or", SCREEN_WIDTH / 2, instructionY); + _display.drawString("open in browser:", SCREEN_WIDTH / 2, instructionY + 18); + + _display.setFont(&fonts::FreeSansBold9pt7b); + _display.setTextColor(COLOR_ACCENT); + _display.drawString("192.168.4.1", SCREEN_WIDTH / 2, instructionY + 40); + + _display.setFont(&fonts::Font0); + _display.setTextColor(COLOR_TEXT_DIM); + _display.drawString("to configure settings", SCREEN_WIDTH / 2, instructionY + 65); + + _display.setTextDatum(lgfx::top_left); +} + void LilyGoDisplay::setSessionId(const char* sessionId) { _sessionId = sessionId ? sessionId : ""; } diff --git a/embedded/libs/lilygo-display/src/lilygo_display.h b/embedded/libs/lilygo-display/src/lilygo_display.h index 8e18beac..1a691eb6 100644 --- a/embedded/libs/lilygo-display/src/lilygo_display.h +++ b/embedded/libs/lilygo-display/src/lilygo_display.h @@ -200,6 +200,7 @@ class LilyGoDisplay { void showConnecting(); void showError(const char* message, const char* ipAddress = nullptr); void showConfigPortal(const char* apName, const char* ip); + void showSetupScreen(const char* apName); // Climb display void showClimb(const char* name, const char* grade, const char* gradeColor, int angle, const char* uuid, diff --git a/embedded/libs/wifi-utils/src/wifi_utils.cpp b/embedded/libs/wifi-utils/src/wifi_utils.cpp index a62149fd..064f33cc 100644 --- a/embedded/libs/wifi-utils/src/wifi_utils.cpp +++ b/embedded/libs/wifi-utils/src/wifi_utils.cpp @@ -49,6 +49,47 @@ void WiFiUtils::disconnect() { setState(WiFiConnectionState::DISCONNECTED); } +bool WiFiUtils::startAP(const char* apName) { + // Stop any existing connection first + WiFi.disconnect(); + + // Clear in-memory credentials to prevent checkConnection() from + // attempting reconnection with stale credentials if we later + // transition out of AP mode + currentSSID = ""; + currentPassword = ""; + + // Configure AP mode + WiFi.mode(WIFI_AP); + + // Start the access point + bool success = WiFi.softAP(apName); + if (success) { + setState(WiFiConnectionState::AP_MODE); + } + return success; +} + +void WiFiUtils::stopAP() { + WiFi.softAPdisconnect(true); + WiFi.mode(WIFI_STA); + WiFi.setAutoReconnect(true); + setState(WiFiConnectionState::DISCONNECTED); +} + +bool WiFiUtils::isAPMode() { + return state == WiFiConnectionState::AP_MODE; +} + +String WiFiUtils::getAPIP() { + return WiFi.softAPIP().toString(); +} + +bool WiFiUtils::hasSavedCredentials() { + String ssid = Config.getString(KEY_SSID); + return ssid.length() > 0; +} + bool WiFiUtils::isConnected() { return WiFi.status() == WL_CONNECTED; } @@ -83,6 +124,11 @@ void WiFiUtils::setState(WiFiConnectionState newState) { } void WiFiUtils::checkConnection() { + // Don't check STA connection in AP mode + if (state == WiFiConnectionState::AP_MODE) { + return; + } + bool connected = WiFi.status() == WL_CONNECTED; switch (state) { @@ -109,5 +155,9 @@ void WiFiUtils::checkConnection() { connect(currentSSID.c_str(), currentPassword.c_str(), false); } break; + + case WiFiConnectionState::AP_MODE: + // Handled at the top of the function + break; } } diff --git a/embedded/libs/wifi-utils/src/wifi_utils.h b/embedded/libs/wifi-utils/src/wifi_utils.h index 10296982..fc44bfcf 100644 --- a/embedded/libs/wifi-utils/src/wifi_utils.h +++ b/embedded/libs/wifi-utils/src/wifi_utils.h @@ -8,8 +8,10 @@ #define WIFI_CONNECT_TIMEOUT_MS 30000 #define WIFI_RECONNECT_INTERVAL_MS 5000 +#define DEFAULT_AP_NAME "Boardsesh-Setup" +#define DEFAULT_AP_IP "192.168.4.1" -enum class WiFiConnectionState { DISCONNECTED, CONNECTING, CONNECTED, CONNECTION_FAILED }; +enum class WiFiConnectionState { DISCONNECTED, CONNECTING, CONNECTED, CONNECTION_FAILED, AP_MODE }; typedef void (*WiFiStateCallback)(WiFiConnectionState state); @@ -24,6 +26,13 @@ class WiFiUtils { bool connectSaved(); void disconnect(); + // Access Point mode + bool startAP(const char* apName = DEFAULT_AP_NAME); + void stopAP(); + bool isAPMode(); + String getAPIP(); + bool hasSavedCredentials(); + bool isConnected(); WiFiConnectionState getState(); diff --git a/embedded/projects/board-controller/src/main.cpp b/embedded/projects/board-controller/src/main.cpp index 9c3a914c..85026ac7 100644 --- a/embedded/projects/board-controller/src/main.cpp +++ b/embedded/projects/board-controller/src/main.cpp @@ -29,6 +29,7 @@ // State bool wifiConnected = false; bool backendConnected = false; +bool bleInitialized = false; #ifdef ENABLE_DISPLAY // Navigation mutation debounce - wait for rapid presses to stop before sending mutation @@ -86,6 +87,7 @@ void onBLEData(const uint8_t* data, size_t len); void onBLELedData(const LedCommand* commands, int count, int angle); void onGraphQLStateChange(GraphQLConnectionState state); void onGraphQLMessage(JsonDocument& doc); +void initializeBLE(); #ifdef ENABLE_DISPLAY void handleLedUpdateExtended(JsonObject& data); void onQueueSync(const ControllerQueueSyncData& data); @@ -106,6 +108,47 @@ void sendToAppViaBLE(const uint8_t* data, size_t len) { } #endif +/** + * Initialize BLE - called after WiFi is configured + * This is deferred until WiFi is set up so we don't waste resources + * scanning for boards when we can't connect to the backend anyway + */ +void initializeBLE() { + if (bleInitialized) { + return; // Already initialized + } + + Logger.logln("Initializing BLE as '%s'...", BLE_DEVICE_NAME); +#ifdef ENABLE_BLE_PROXY + // When proxy is enabled, don't advertise yet - connect to board first + // Advertising will start after successful connection to real board + BLE.begin(BLE_DEVICE_NAME, false); +#else + BLE.begin(BLE_DEVICE_NAME, true); +#endif + BLE.setConnectCallback(onBLEConnect); + BLE.setDataCallback(onBLEData); + BLE.setLedDataCallback(onBLELedData); + +#ifdef ENABLE_BLE_PROXY + // Set up raw data forwarding for proxy mode + BLE.setRawForwardCallback(onBLERawForward); + + // Initialize proxy + String targetMac = Config.getString("proxy_mac"); + Proxy.begin(targetMac); + Proxy.setStateCallback(onProxyStateChange); + Proxy.setSendToAppCallback(sendToAppViaBLE); +#endif + +#ifdef ENABLE_DISPLAY + Display.setBleStatus(true, false); // BLE enabled, not connected +#endif + + bleInitialized = true; + Logger.logln("BLE initialization complete"); +} + void setup() { Serial.begin(115200); delay(3000); // Longer delay to ensure serial monitor catches boot messages @@ -162,36 +205,27 @@ void setup() { // Try to connect to saved WiFi if (!WiFiMgr.connectSaved()) { - Logger.logln("No saved WiFi credentials"); - } - - // Initialize BLE - always use BLE_DEVICE_NAME for Kilter app compatibility - Logger.logln("Initializing BLE as '%s'...", BLE_DEVICE_NAME); -#ifdef ENABLE_BLE_PROXY - // When proxy is enabled, don't advertise yet - connect to board first - // Advertising will start after successful connection to real board - BLE.begin(BLE_DEVICE_NAME, false); + Logger.logln("No saved WiFi credentials - starting AP mode"); +#ifdef ENABLE_DISPLAY + // Start AP mode for WiFi configuration + if (WiFiMgr.startAP()) { + Logger.logln("AP mode started: %s", DEFAULT_AP_NAME); + Display.showSetupScreen(DEFAULT_AP_NAME); + } else { + Logger.logln("Failed to start AP mode"); + Display.showError("AP Failed"); + } #else - BLE.begin(BLE_DEVICE_NAME, true); -#endif - BLE.setConnectCallback(onBLEConnect); - BLE.setDataCallback(onBLEData); - BLE.setLedDataCallback(onBLELedData); - -#ifdef ENABLE_BLE_PROXY - // Set up raw data forwarding for proxy mode - BLE.setRawForwardCallback(onBLERawForward); - - // Initialize proxy - String targetMac = Config.getString("proxy_mac"); - Proxy.begin(targetMac); - Proxy.setStateCallback(onProxyStateChange); - Proxy.setSendToAppCallback(sendToAppViaBLE); + // Without display, just start AP mode silently + WiFiMgr.startAP(); #endif + // Don't initialize BLE yet - wait for WiFi to be configured + } else { + // We have saved WiFi credentials, initialize BLE now + initializeBLE(); + } #ifdef ENABLE_DISPLAY - Display.setBleStatus(true, false); // BLE enabled, not connected - // Initialize button pins for navigation pinMode(BUTTON_1_PIN, INPUT_PULLUP); pinMode(BUTTON_2_PIN, INPUT_PULLUP); @@ -202,13 +236,20 @@ void setup() { WebConfig.begin(); Logger.logln("Setup complete!"); - Logger.logln("IP: %s", WiFiMgr.getIP().c_str()); + if (WiFiMgr.isAPMode()) { + Logger.logln("AP IP: %s", WiFiMgr.getAPIP().c_str()); + } else { + Logger.logln("IP: %s", WiFiMgr.getIP().c_str()); + } // Green blink to indicate ready LEDs.blink(0, 255, 0, 3, 100); + // Don't refresh display if in AP mode - keep showing setup screen #ifdef ENABLE_DISPLAY - Display.refresh(); + if (!WiFiMgr.isAPMode()) { + Display.refresh(); + } #endif } @@ -216,13 +257,15 @@ void loop() { // Process WiFi WiFiMgr.loop(); - // Process BLE - BLE.loop(); + // Process BLE (only if initialized - deferred until WiFi configured) + if (bleInitialized) { + BLE.loop(); #ifdef ENABLE_BLE_PROXY - // Process BLE proxy - Proxy.loop(); + // Process BLE proxy + Proxy.loop(); #endif + } // Process WebSocket if WiFi connected if (wifiConnected) { @@ -323,8 +366,13 @@ void onWiFiStateChange(WiFiConnectionState state) { #ifdef ENABLE_DISPLAY Display.setWiFiStatus(true); + // Show normal UI now that we're connected + Display.showNoClimb(); #endif + // Initialize BLE now that WiFi is connected (if not already done) + initializeBLE(); + // Get backend config String host = Config.getString("backend_host", DEFAULT_BACKEND_HOST); int port = Config.getInt("backend_port", DEFAULT_BACKEND_PORT); @@ -370,6 +418,22 @@ void onWiFiStateChange(WiFiConnectionState state) { Logger.logln("WiFi connection failed"); #ifdef ENABLE_DISPLAY Display.setWiFiStatus(false); +#endif + // If connection failed and we don't have saved credentials, start AP mode + if (!WiFiMgr.hasSavedCredentials()) { + Logger.logln("No saved credentials - starting AP mode for configuration"); + if (WiFiMgr.startAP()) { +#ifdef ENABLE_DISPLAY + Display.showSetupScreen(DEFAULT_AP_NAME); +#endif + } + } + break; + + case WiFiConnectionState::AP_MODE: + Logger.logln("WiFi in AP mode: %s", WiFiMgr.getAPIP().c_str()); +#ifdef ENABLE_DISPLAY + Display.setWiFiStatus(false); #endif break; } diff --git a/embedded/test/lib/mocks/src/WiFi.h b/embedded/test/lib/mocks/src/WiFi.h index 7d9ea859..1a24020f 100644 --- a/embedded/test/lib/mocks/src/WiFi.h +++ b/embedded/test/lib/mocks/src/WiFi.h @@ -80,7 +80,7 @@ class MockWiFi { MockWiFi() : status_(WL_DISCONNECTED), mode_(WIFI_OFF), autoReconnect_(false), rssi_(-70), localIP_(192, 168, 1, 100), - ssid_(""), macAddress_("AA:BB:CC:DD:EE:FF") {} + ssid_(""), macAddress_("AA:BB:CC:DD:EE:FF"), apActive_(false), apSSID_(""), apIP_(192, 168, 4, 1) {} // Mode control bool mode(wifi_mode_t mode) { @@ -123,6 +123,23 @@ class MockWiFi { String macAddress() const { return macAddress_; } + // Access Point methods + bool softAP(const char* ssid, const char* passphrase = nullptr) { + (void)passphrase; + apSSID_ = ssid ? ssid : ""; + apActive_ = true; + return true; + } + + bool softAPdisconnect(bool wifioff = false) { + (void)wifioff; + apActive_ = false; + apSSID_ = ""; + return true; + } + + IPAddress softAPIP() const { return apIP_; } + // Scan methods int16_t scanNetworks() { return networks_.size(); } @@ -171,6 +188,9 @@ class MockWiFi { ssid_ = ""; macAddress_ = "AA:BB:CC:DD:EE:FF"; networks_.clear(); + apActive_ = false; + apSSID_ = ""; + apIP_ = IPAddress(192, 168, 4, 1); } private: @@ -182,6 +202,9 @@ class MockWiFi { String ssid_; String macAddress_; std::vector networks_; + bool apActive_ = false; + String apSSID_; + IPAddress apIP_ = IPAddress(192, 168, 4, 1); }; extern MockWiFi WiFi; diff --git a/embedded/test/test_wifi_utils/test_wifi_utils.cpp b/embedded/test/test_wifi_utils/test_wifi_utils.cpp index 744f984c..235733bf 100644 --- a/embedded/test/test_wifi_utils/test_wifi_utils.cpp +++ b/embedded/test/test_wifi_utils/test_wifi_utils.cpp @@ -293,6 +293,88 @@ void test_getState_reflects_current_state(void) { TEST_ASSERT_EQUAL(WiFiConnectionState::CONNECTED, wifiMgr->getState()); } +// ============================================================================= +// AP Mode Tests +// ============================================================================= + +void test_startAP_sets_ap_mode(void) { + wifiMgr->begin(); + bool result = wifiMgr->startAP("TestAP"); + + TEST_ASSERT_TRUE(result); + TEST_ASSERT_TRUE(wifiMgr->isAPMode()); + TEST_ASSERT_EQUAL(WiFiConnectionState::AP_MODE, wifiMgr->getState()); +} + +void test_startAP_clears_credentials_prevents_reconnect_loop(void) { + wifiMgr->begin(); + + // Simulate a failed connection attempt that sets currentSSID/currentPassword + wifiMgr->connect("FailingNetwork", "password123", false); + TEST_ASSERT_EQUAL(WiFiConnectionState::CONNECTING, wifiMgr->getState()); + + // Enter AP mode - should clear in-memory credentials + wifiMgr->startAP("SetupAP"); + TEST_ASSERT_EQUAL(WiFiConnectionState::AP_MODE, wifiMgr->getState()); + + // Stop AP mode - transitions to DISCONNECTED + wifiMgr->stopAP(); + TEST_ASSERT_EQUAL(WiFiConnectionState::DISCONNECTED, wifiMgr->getState()); + + // Run the loop - checkConnection should NOT attempt reconnection + // because currentSSID was cleared by startAP() + wifiMgr->loop(); + + // Should remain DISCONNECTED (not transition to CONNECTING) + TEST_ASSERT_EQUAL(WiFiConnectionState::DISCONNECTED, wifiMgr->getState()); +} + +void test_startAP_after_failed_connection(void) { + wifiMgr->begin(); + wifiMgr->setStateCallback(testStateCallback); + callbackCount = 0; + + // Start a connection that will fail + wifiMgr->connect("BadNetwork", "badpass", false); + + // Enter AP mode + wifiMgr->startAP("SetupAP"); + TEST_ASSERT_EQUAL(WiFiConnectionState::AP_MODE, wifiMgr->getState()); + TEST_ASSERT_EQUAL(WIFI_AP, WiFi.getMode()); +} + +void test_stopAP_restores_sta_mode(void) { + wifiMgr->begin(); + wifiMgr->startAP("TestAP"); + + wifiMgr->stopAP(); + TEST_ASSERT_FALSE(wifiMgr->isAPMode()); + TEST_ASSERT_EQUAL(WIFI_STA, WiFi.getMode()); + TEST_ASSERT_EQUAL(WiFiConnectionState::DISCONNECTED, wifiMgr->getState()); +} + +void test_loop_skips_check_in_ap_mode(void) { + wifiMgr->begin(); + wifiMgr->startAP("TestAP"); + + // Even if WiFi reports connected (shouldn't in AP mode), state should stay AP_MODE + WiFi.mockSetStatus(WL_CONNECTED); + wifiMgr->loop(); + + TEST_ASSERT_EQUAL(WiFiConnectionState::AP_MODE, wifiMgr->getState()); +} + +void test_hasSavedCredentials_with_saved(void) { + Config.setString(WiFiUtils::KEY_SSID, "SavedNet"); + Config.setString(WiFiUtils::KEY_PASSWORD, "SavedPass"); + + TEST_ASSERT_TRUE(wifiMgr->hasSavedCredentials()); +} + +void test_hasSavedCredentials_without_saved(void) { + TEST_ASSERT_FALSE(wifiMgr->hasSavedCredentials()); +} + // ============================================================================= // Config Key Tests // ============================================================================= @@ -353,6 +435,15 @@ int main(int argc, char** argv) { RUN_TEST(test_loop_when_not_connecting); RUN_TEST(test_getState_reflects_current_state); + // AP mode tests + RUN_TEST(test_startAP_sets_ap_mode); + RUN_TEST(test_startAP_clears_credentials_prevents_reconnect_loop); + RUN_TEST(test_startAP_after_failed_connection); + RUN_TEST(test_stopAP_restores_sta_mode); + RUN_TEST(test_loop_skips_check_in_ap_mode); + RUN_TEST(test_hasSavedCredentials_with_saved); + RUN_TEST(test_hasSavedCredentials_without_saved); + // Config key tests RUN_TEST(test_key_constants);