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..3d0df1f0 100644
--- a/embedded/libs/wifi-utils/src/wifi_utils.cpp
+++ b/embedded/libs/wifi-utils/src/wifi_utils.cpp
@@ -49,6 +49,41 @@ void WiFiUtils::disconnect() {
setState(WiFiConnectionState::DISCONNECTED);
}
+bool WiFiUtils::startAP(const char* apName) {
+ // Stop any existing connection first
+ WiFi.disconnect();
+
+ // 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 +118,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 +149,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..d9012f80 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);
@@ -368,6 +416,20 @@ void onWiFiStateChange(WiFiConnectionState state) {
case WiFiConnectionState::CONNECTION_FAILED:
Logger.logln("WiFi connection failed");
+#ifdef ENABLE_DISPLAY
+ Display.setWiFiStatus(false);
+ // 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()) {
+ 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