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);