diff --git a/README.md b/README.md index 86a2e0b3..a10ca544 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # WiFiManager +https://github.com/user-attachments/assets/1e86e083-0089-4639-b95f-392b61905f0a + Espressif ESPx WiFi Connection manager with fallback web configuration portal :warning: This Documentation is out of date, see notes below diff --git a/WiFiManager.cpp b/WiFiManager.cpp index 21c9e38f..10c9e455 100644 --- a/WiFiManager.cpp +++ b/WiFiManager.cpp @@ -343,6 +343,7 @@ boolean WiFiManager::autoConnect(char const *apName, char const *apPassword) { DEBUG_WM(F("AutoConnect: ESP Already Connected")); #endif setSTAConfig(); + setLEDState(WM_LED_CONNECTED); // already connected – set LED // @todo not sure if this is safe, causes dup setSTAConfig in connectwifi, // and we have no idea WHAT we are connected to } @@ -657,6 +658,18 @@ void WiFiManager::setupHTTPServer(){ server->on(WM_G(R_update), std::bind(&WiFiManager::handleUpdate, this)); server->on(WM_G(R_updatedone), HTTP_POST, std::bind(&WiFiManager::handleUpdateDone, this), std::bind(&WiFiManager::handleUpdating, this)); + + // OS captive portal compatibility probes – these let Android/iOS/Windows automatically + // pop up the captive-portal UI when a user connects to the SoftAP. + // HTTPS is NOT intercepted; only plain HTTP probes are handled. + if(_captivePortalCompat) { + server->on(F("/generate_204"), std::bind(&WiFiManager::handleCaptivePortal204, this)); // Android + server->on(F("/hotspot-detect.html"), std::bind(&WiFiManager::handleCaptivePortalHotspot, this)); // iOS / macOS + server->on(F("/ncsi.txt"), std::bind(&WiFiManager::handleCaptivePortalNcsi, this)); // Windows + server->on(F("/connecttest.txt"), std::bind(&WiFiManager::handleCaptivePortalNcsi, this)); // Windows 10 + server->on(F("/redirect"), std::bind(&WiFiManager::handleCaptivePortalNcsi, this)); // Windows + server->on(F("/success.txt"), std::bind(&WiFiManager::handleCaptivePortal204, this)); // Amazon Fire OS + } server->begin(); // Web server start #ifdef WM_DEBUG_LEVEL @@ -717,7 +730,8 @@ boolean WiFiManager::startConfigPortal(char const *apName, char const *apPasswo if(!validApPassword()) return false; // HANDLE issues with STA connections, shutdown sta if not connected, or else this will hang channel scanning and softap will not respond - if(_disableSTA || (!WiFi.isConnected() && _disableSTAConn)){ + // When _keepAPDuringSTAConnect is set we intentionally run AP+STA, so we keep STA enabled. + if(!_keepAPDuringSTAConnect && (_disableSTA || (!WiFi.isConnected() && _disableSTAConn))){ // this fixes most ap problems, however, simply doing mode(WIFI_AP) does not work if sta connection is hanging, must `wifi_station_disconnect` #ifdef WM_DISCONWORKAROUND WiFi.mode(WIFI_AP_STA); @@ -728,10 +742,23 @@ boolean WiFiManager::startConfigPortal(char const *apName, char const *apPasswo DEBUG_WM(WM_DEBUG_VERBOSE,F("Disabling STA")); #endif } + else if(_keepAPDuringSTAConnect) { + // Ensure STA interface is up for AP+STA provisioning mode + WiFi_enableSTA(true); + #ifdef WM_DEBUG_LEVEL + DEBUG_WM(WM_DEBUG_VERBOSE,F("Keeping STA enabled (AP+STA provisioning mode)")); + #endif + } else { // WiFi_enableSTA(true); } + // reset provisioning state for a fresh portal session + _provisioningState = WM_PROV_IDLE; + _provisioningError = ""; + _provisioningConnecting = false; + _apShutdownPending = false; + // init configportal globals to known states configPortalActive = true; bool result = connect = abort = false; // loop flags, connect true success, abort true break @@ -746,6 +773,15 @@ boolean WiFiManager::startConfigPortal(char const *apName, char const *apPasswo startAP(); WiFiSetCountry(); + // Set LED state: NOWIFI when no credentials are saved; if credentials exist but the + // LED is not already active (e.g. manual portal start), use FAILED to signal a + // configuration problem (credentials stored but connection could not be established). + if(!WiFi_hasAutoConnect()) { + setLEDState(WM_LED_NOWIFI); + } else if(_ledCurrentState == WM_LED_OFF) { + setLEDState(WM_LED_FAILED); + } + // do AP callback if set if ( _apcallback != NULL) { #ifdef WM_DEBUG_LEVEL @@ -831,7 +867,11 @@ boolean WiFiManager::process(){ #if defined(WM_MDNS) && defined(ESP8266) MDNS.update(); #endif - + + // Always drive the LED state machine so timeouts fire and the LED + // stays accurate even when the config portal is not active. + syncLEDState(); + if(webPortalActive || (configPortalActive && !_configPortalIsBlocking)){ // if timed out or abort, break if(_allowExit && (configPortalHasTimeout() || abort)){ @@ -863,6 +903,9 @@ boolean WiFiManager::process(){ * @return {[type]} [description] */ uint8_t WiFiManager::processConfigPortal(){ + // Check LED timeout and reconcile with WiFi status on every iteration. + syncLEDState(); + if(configPortalActive){ //DNS handler dnsServer->processNextRequest(); @@ -871,6 +914,17 @@ uint8_t WiFiManager::processConfigPortal(){ //HTTP handler server->handleClient(); + // ---- AP+STA provisioning state machine ---- + // When _keepAPDuringSTAConnect is true the connection is started + // non-blocking from handleWifiSave(). checkProvisioningState() advances + // the state machine on every call and returns WL_CONNECTED once the AP + // has been torn down after the shutdown delay. + if(_keepAPDuringSTAConnect && (_provisioningConnecting || _apShutdownPending)) { + uint8_t provResult = checkProvisioningState(); + if(provResult != WL_IDLE_STATUS) return provResult; + return WL_IDLE_STATUS; + } + // Waiting for save... if(connect) { connect = false; @@ -949,6 +1003,223 @@ uint8_t WiFiManager::processConfigPortal(){ return WL_IDLE_STATUS; } +// --------------------------------------------------------------------------- +// AP+STA Provisioning state machine +// --------------------------------------------------------------------------- + +/** + * checkProvisioningState + * Called from processConfigPortal() when _keepAPDuringSTAConnect is true. + * Polls the STA connection status and drives the provisioning state machine: + * CONNECTING → CONNECTED (saves credentials, starts AP-shutdown timer) + * CONNECTING → FAILED (keeps portal open so the user can retry) + * CONNECTED → shutdown (once the AP-shutdown delay has elapsed) + * + * @return WL_IDLE_STATUS while still in progress, WL_CONNECTED when the AP + * has been torn down after a successful connection. + */ +uint8_t WiFiManager::checkProvisioningState() { + // ---- AP shutdown timer expired after successful connect ---- + if(_apShutdownPending) { + if(millis() >= _apShutdownDeadline) { + _apShutdownPending = false; + #ifdef WM_DEBUG_LEVEL + DEBUG_WM(WM_DEBUG_VERBOSE,F("Provisioning: AP shutdown delay elapsed, shutting down AP")); + #endif + shutdownConfigPortal(); // stops AP, sets configPortalActive = false + return WL_CONNECTED; // signal the blocking loop to exit with result=true + } + return WL_IDLE_STATUS; // still waiting + } + + if(!_provisioningConnecting) return WL_IDLE_STATUS; + + uint8_t status = WiFi.status(); + + // Default connect-timeout is _connectTimeout; fall back to 30 s if not set + unsigned long timeout = (_connectTimeout > 0) ? _connectTimeout : 30000UL; + unsigned long elapsed = millis() - _startconn; + bool timedOut = elapsed > timeout; + + // Grace period after calling WiFi.begin(): the radio briefly retains the + // previous failure status from the last attempt. Ignore failure codes + // until the radio has had time to reset so a retry does not instantly re-fail. + static const unsigned long PROV_GRACE_PERIOD_MS = 2000UL; + bool gracePeriod = elapsed < PROV_GRACE_PERIOD_MS; + + if(status == WL_CONNECTED) { + // Wait for DHCP to assign a real IP before declaring success. + // WiFi.status() can become WL_CONNECTED before the IP address is assigned + // (localIP() returns 0.0.0.0 during that window). 8 s covers even slow + // DHCP servers; typical assignment completes in under 2 s. + static const unsigned long DHCP_IP_WAIT_MS = 8000UL; + if(WiFi.localIP() == IPAddress(0,0,0,0)) { + if(_ipWaitStart == 0) _ipWaitStart = millis(); + if(millis() - _ipWaitStart < DHCP_IP_WAIT_MS) { + return WL_IDLE_STATUS; // still waiting for DHCP + } + // timeout – proceed anyway so we don't get stuck forever + } + _ipWaitStart = 0; + + #ifdef WM_DEBUG_LEVEL + DEBUG_WM(WM_DEBUG_VERBOSE,F("Provisioning: STA connected, IP:"),WiFi.localIP()); + #endif + _provisioningState = WM_PROV_CONNECTED; + _provisioningConnecting = false; + updateConxResult(status); + + // Save credentials NOW that the connection is confirmed working + saveWiFiCredentials(_ssid, _pass); + + // Fire the save callback + if(_savewificallback != NULL) { + #ifdef WM_DEBUG_LEVEL + DEBUG_WM(WM_DEBUG_VERBOSE,F("[CB] _savewificallback calling")); + #endif + _savewificallback(); // @CALLBACK + } + + // Start the AP-shutdown delay (or shut down immediately if delay == 0) + if(_apShutdownDelayMs > 0) { + _apShutdownDeadline = millis() + _apShutdownDelayMs; + _apShutdownPending = true; + #ifdef WM_DEBUG_LEVEL + DEBUG_WM(WM_DEBUG_VERBOSE,F("Provisioning: AP will shut down in ms:"),_apShutdownDelayMs); + #endif + } else { + shutdownConfigPortal(); + return WL_CONNECTED; + } + + } else if(!gracePeriod && ( + status == WL_NO_SSID_AVAIL || + status == WL_CONNECT_FAILED || + status == WL_CONNECTION_LOST || + status == WL_STATION_WRONG_PASSWORD || + timedOut)) { + + #ifdef WM_DEBUG_LEVEL + DEBUG_WM(WM_DEBUG_ERROR,F("Provisioning: STA connection failed, status:"),getWLStatusString(status)); + #endif + _provisioningState = WM_PROV_FAILED; + _provisioningConnecting = false; + updateConxResult(status); + // Always populate the error so the frontend can show a helpful message. + _provisioningError = getProvisioningFailureReason(timedOut ? WL_IDLE_STATUS : status); + // Keep AP + portal open so the user can retry + } + + return WL_IDLE_STATUS; +} + +/** + * saveWiFiCredentials + * Persist SSID / password to flash using WiFi.begin(connect=false) so that + * the credential store is updated without starting a new connection attempt + * (the STA is already connected at this point). + */ +bool WiFiManager::saveWiFiCredentials(String ssid, String pass) { + #ifdef WM_DEBUG_LEVEL + DEBUG_WM(WM_DEBUG_VERBOSE,F("Saving WiFi credentials to persistent storage")); + #endif + WiFi_enableSTA(true, storeSTAmode); + WiFi.persistent(true); + // The 5-parameter form of WiFi.begin() is supported on both ESP32 (arduino-esp32) + // and ESP8266 (arduino-esp8266): the last parameter `connect` defaults to true and + // is already used by wifiConnectNew() in this library. + // Passing connect=false saves the credentials to NVS / flash (because persistent=true) + // without calling esp_wifi_connect() / wifi_station_connect(), so the existing + // STA session is preserved. + bool ret = WiFi.begin(ssid.c_str(), pass.c_str(), 0, NULL, false); + WiFi.persistent(false); + return ret; +} + +/** + * getProvisioningStateStr + * Returns the current provisioning state as a JSON-safe string. + */ +String WiFiManager::getProvisioningStateStr() { + switch(_provisioningState) { + case WM_PROV_SCANNING: return F("scanning"); + case WM_PROV_CONNECTING: return F("connecting"); + case WM_PROV_CONNECTED: return F("connected"); + case WM_PROV_FAILED: return F("failed"); + case WM_PROV_RETRYING: return F("retrying"); + default: return F("idle"); + } +} + +/** + * getProvisioningFailureReason + * Maps low-level WL status codes to a human-readable error string. + */ +String WiFiManager::getProvisioningFailureReason(uint8_t status) { + if(status == WL_NO_SSID_AVAIL) return F("Network not found"); + if(status == WL_STATION_WRONG_PASSWORD) return F("Wrong password"); + if(status == WL_CONNECT_FAILED) return F("Connection failed"); + if(status == WL_CONNECTION_LOST) return F("Connection lost / wrong password"); + if(status == WL_IDLE_STATUS) return F("Connection timed out"); + return F("Unknown error"); +} + +// --------------------------------------------------------------------------- +// OS captive portal probe handlers +// --------------------------------------------------------------------------- + +/** + * handleCaptivePortal204 + * Android: GET /generate_204 – Android expects a 204 response on a real + * internet connection. When in captive portal mode we redirect to the portal + * so Android pops up the "Sign in to network" notification. + * Amazon Fire OS: GET /success.txt (same treatment). + */ +void WiFiManager::handleCaptivePortal204() { + if(!configPortalActive) { + server->send(204, FPSTR(HTTP_HEAD_CT2), ""); + return; + } + String loc = (String)F("http://") + toStringIp(WiFi.softAPIP()); + server->sendHeader(F("Location"), loc, true); + server->send(302, FPSTR(HTTP_HEAD_CT2), ""); + server->client().stop(); +} + +/** + * handleCaptivePortalHotspot + * iOS / macOS: GET /hotspot-detect.html – Apple expects a specific response + * body. We redirect to the portal instead so the Captive Network Assistant opens. + */ +void WiFiManager::handleCaptivePortalHotspot() { + if(!configPortalActive) { + server->send(200, FPSTR(HTTP_HEAD_CT), + F("SuccessSuccess")); + return; + } + String loc = (String)F("http://") + toStringIp(WiFi.softAPIP()); + server->sendHeader(F("Location"), loc, true); + server->send(302, FPSTR(HTTP_HEAD_CT2), ""); + server->client().stop(); +} + +/** + * handleCaptivePortalNcsi + * Windows: GET /ncsi.txt, /connecttest.txt, /redirect – NCSI (Network + * Connectivity Status Indicator) probes. Redirect to the portal so Windows + * displays the "Additional sign-in info required" notification. + */ +void WiFiManager::handleCaptivePortalNcsi() { + if(!configPortalActive) { + server->send(200, FPSTR(HTTP_HEAD_CT2), F("Microsoft NCSI")); + return; + } + String loc = (String)F("http://") + toStringIp(WiFi.softAPIP()); + server->sendHeader(F("Location"), loc, true); + server->send(302, FPSTR(HTTP_HEAD_CT2), ""); + server->client().stop(); +} + /** * [shutdownConfigPortal description] * @access public @@ -1022,6 +1293,8 @@ uint8_t WiFiManager::connectWifi(String ssid, String pass, bool connect) { uint8_t retry = 1; uint8_t connRes = (uint8_t)WL_NO_SSID_AVAIL; + setLEDState(WM_LED_CONNECTING); + setSTAConfig(); //@todo catch failures in set_config @@ -1212,6 +1485,13 @@ void WiFiManager::updateConxResult(uint8_t status){ } DEBUG_WM(WM_DEBUG_DEV,F("lastconxresult:"),getWLStatusString(_lastconxresult)); #endif + + // Update LED state based on connection result + if(_lastconxresult == WL_CONNECTED) { + setLEDState(WM_LED_CONNECTED); + } else if(_lastconxresult != WL_IDLE_STATUS) { + setLEDState(WM_LED_FAILED); + } } @@ -1346,6 +1626,16 @@ void WiFiManager::handleRoot() { DEBUG_WM(WM_DEBUG_VERBOSE,F("<- HTTP Root")); #endif if (captivePortal()) return; // If captive portal redirect instead of displaying the page + + // When the setup portal is active, go directly to the WiFi config page + // so users land on the useful page immediately instead of a menu. + if(configPortalActive) { + server->sendHeader(F("Location"), F("/wifi"), true); + server->send(302, FPSTR(HTTP_HEAD_CT2), ""); + server->client().stop(); + return; + } + handleRequest(); String page = getHTTPHead(_title, FPSTR(C_root)); // @token options @todo replace options with title String str = FPSTR(HTTP_ROOT_MAIN); // @todo custom title @@ -1355,6 +1645,7 @@ void WiFiManager::handleRoot() { page += FPSTR(HTTP_PORTAL_OPTIONS); page += getMenuOut(); reportStatus(page); + page += FPSTR(HTTP_NAV_BOTTOM); page += getHTTPEnd(); HTTPSend(page); @@ -1373,6 +1664,13 @@ void WiFiManager::handleWifi(boolean scan) { #endif handleRequest(); String page = getHTTPHead(FPSTR(S_titlewifi), FPSTR(C_wifi)); // @token titlewifi + + // Status banner + refresh icon button in a flex row + page += F("
"); + reportStatus(page); + page += FPSTR(HTTP_SCAN_LINK); // refresh icon button + page += F("
"); + if (scan) { #ifdef WM_DEBUG_LEVEL // DEBUG_WM(WM_DEBUG_DEV,"refresh flag:",server->hasArg(F("refresh"))); @@ -1387,16 +1685,24 @@ void WiFiManager::handleWifi(boolean scan) { page += pitem; pitem = FPSTR(HTTP_FORM_WIFI); - pitem.replace(FPSTR(T_v), WiFi_SSID()); - if(_showPassword){ - pitem.replace(FPSTR(T_p), WiFi_psk()); - } - else if(WiFi_psk() != ""){ - pitem.replace(FPSTR(T_p),FPSTR(S_passph)); + // After a failed provisioning attempt pre-fill with the last-tried credentials so + // the user can review (and reveal) exactly what was submitted. Otherwise fall back + // to any saved SSID so the field is not empty. + String formSSID = (_provisioningState == WM_PROV_FAILED && !_ssid.isEmpty()) ? _ssid : WiFi_SSID(); + pitem.replace(FPSTR(T_v), htmlEntities(formSSID)); + + if(_provisioningState == WM_PROV_FAILED && !_pass.isEmpty()) { + // Pre-fill with last-tried password (masked by type=password; eye button reveals it) + pitem.replace(FPSTR(T_p), htmlEntities(_pass)); + } else if(_showPassword) { + pitem.replace(FPSTR(T_p), htmlEntities(WiFi_psk())); } else { - pitem.replace(FPSTR(T_p),""); + // Leave empty – the S_passph sentinel ("********") cannot be + // toggled with the eye button to reveal the real password, so we + // just leave the field blank and let the user type it again. + pitem.replace(FPSTR(T_p),""); } page += pitem; @@ -1408,9 +1714,14 @@ void WiFiManager::handleWifi(boolean scan) { page += getParamOut(); } page += FPSTR(HTTP_FORM_END); - page += FPSTR(HTTP_SCAN_LINK); if(_showBack) page += FPSTR(HTTP_BACKBTN); - reportStatus(page); + + // Bottom navigation bar + page += FPSTR(HTTP_NAV_BOTTOM); + + // Live status polling script – updates the status banner dynamically + page += FPSTR(HTTP_STATUS_LIVE_SCRIPT); + page += getHTTPEnd(); HTTPSend(page); @@ -1440,6 +1751,7 @@ void WiFiManager::handleParam(){ page += FPSTR(HTTP_FORM_END); if(_showBack) page += FPSTR(HTTP_BACKBTN); reportStatus(page); + page += FPSTR(HTTP_NAV_BOTTOM); page += getHTTPEnd(); HTTPSend(page); @@ -1472,8 +1784,9 @@ String WiFiManager::getMenuOut(){ // } void WiFiManager::WiFi_scanComplete(int networksFound){ - _lastscan = millis(); + _lastscan = millis(); _numNetworks = networksFound; + _scanFailed = false; // async scan completed (even if 0 networks, it didn't fail) #ifdef WM_DEBUG_LEVEL DEBUG_WM(WM_DEBUG_VERBOSE,F("WiFi Scan ASYNC completed"), "in "+(String)(_lastscan - _startscan)+" ms"); DEBUG_WM(WM_DEBUG_VERBOSE,F("WiFi Scan ASYNC found:"),_numNetworks); @@ -1540,6 +1853,7 @@ bool WiFiManager::WiFi_scanNetworks(bool force,bool async){ #ifdef WM_DEBUG_LEVEL DEBUG_WM(WM_DEBUG_ERROR,F("[ERROR] scan failed")); #endif + _scanFailed = true; } else if(res == WIFI_SCAN_RUNNING){ #ifdef WM_DEBUG_LEVEL @@ -1552,8 +1866,9 @@ bool WiFiManager::WiFi_scanNetworks(bool force,bool async){ delay(100); } _numNetworks = WiFi.scanComplete(); + _scanFailed = false; } - else if(res >=0 ) _numNetworks = res; + else if(res >=0 ) { _numNetworks = res; _scanFailed = false; } _lastscan = millis(); #ifdef WM_DEBUG_LEVEL DEBUG_WM(WM_DEBUG_VERBOSE,F("WiFi Scan completed"), "in "+(String)(_lastscan - _startscan)+" ms"); @@ -1576,10 +1891,12 @@ String WiFiManager::WiFiManager::getScanItemOut(){ int n = _numNetworks; if (n == 0) { #ifdef WM_DEBUG_LEVEL - DEBUG_WM(F("No networks found")); + if(_scanFailed) DEBUG_WM(F("Scan failed")); + else DEBUG_WM(F("No networks found")); #endif - page += FPSTR(S_nonetworks); // @token nonetworks - page += F("

"); + page += F("

"); + page += _scanFailed ? FPSTR(S_scanfailed) : FPSTR(S_nonetworks); + page += F("

"); } else { #ifdef WM_DEBUG_LEVEL @@ -1640,6 +1957,9 @@ String WiFiManager::WiFiManager::getScanItemOut(){ bool tok_q = HTTP_ITEM_STR.indexOf(FPSTR(T_q)) > 0; bool tok_i = HTTP_ITEM_STR.indexOf(FPSTR(T_i)) > 0; + // Wrap network items in a scrollable div + page += F("
"); + //display networks in page for (int i = 0; i < n; i++) { if (indices[i] == -1) continue; // skip dups @@ -1682,7 +2002,7 @@ String WiFiManager::WiFiManager::getScanItemOut(){ } } - page += FPSTR(HTTP_BR); + page += F("
"); // close .wl scrollable div } return page; @@ -1808,12 +2128,59 @@ void WiFiManager::handleWiFiStatus(){ DEBUG_WM(WM_DEBUG_VERBOSE,F("<- HTTP WiFi status ")); #endif handleRequest(); - String page; - // String page = "{\"result\":true,\"count\":1}"; - #ifdef WM_JSTEST - page = FPSTR(HTTP_JS); + + // Build a JSON response with the current provisioning / connection state. + // This endpoint is polled by the provisioning UI page after a save and by + // the live status script on the WiFi-setup page. + bool connected = (WiFi.status() == WL_CONNECTED); + + String json = F("{\"state\":\""); + json += getProvisioningStateStr(); + json += F("\",\"ssid\":\""); + json += htmlEntities(WiFi_SSID()); + json += F("\",\"ip\":\""); + if(connected) json += WiFi.localIP().toString(); + json += F("\",\"error\":\""); + json += _provisioningError; + json += F("\",\"wlstatus\":\""); + json += connected ? F("connected") : F("disconnected"); + json += F("\""); + + if(connected) { + int rssi = WiFi.RSSI(); + json += F(",\"rssi\":"); + json += String(rssi); + json += F(",\"quality\":"); + json += String(getRSSIasQuality(rssi)); + } + + // Map last connection result to a short machine-readable string so the + // frontend can show user-friendly error messages. + json += F(",\"lastResult\":\""); + switch(_lastconxresult) { + case WL_STATION_WRONG_PASSWORD: json += F("wrong_password"); break; + case WL_NO_SSID_AVAIL: json += F("not_found"); break; + case WL_CONNECT_FAILED: + case WL_CONNECTION_LOST: json += F("failed"); break; + case WL_CONNECTED: json += F("connected"); break; + default: break; // WL_IDLE_STATUS – no attempt yet + } + json += F("\""); + + if(_apShutdownPending && _apShutdownDeadline > millis()) { + json += F(",\"apShutdownIn\":"); + json += String((long)(_apShutdownDeadline - millis())); + } + #ifdef WM_MDNS + if(_hostname != "") { + json += F(",\"hostname\":\""); + json += _hostname + F(".local"); + json += F("\""); + } #endif - HTTPSend(page); + json += F("}"); + + server->send(200, F("application/json"), json); } /** @@ -1837,6 +2204,24 @@ void WiFiManager::handleWifiSave() { #endif } + // --- Server-side input validation --- + // WPA2-Personal PSK: 8–63 printable ASCII characters; empty = open network. + // The client-side HTML pattern enforces the same rule, but we guard here + // too so a raw HTTP POST cannot bypass it. + size_t passLen = _pass.length(); + if (passLen > 0 && (passLen < 8 || passLen > 63)) { + #ifdef WM_DEBUG_LEVEL + DEBUG_WM(WM_DEBUG_ERROR,F("[ERROR] WiFi password length invalid:"),passLen); + #endif + String page = getHTTPHead(FPSTR(S_titlewifisaved), FPSTR(C_wifi)); + page += F("
Invalid password
" + "WiFi password must be between 8 and 63 characters.
" + "

"); + page += getHTTPEnd(); + server->sendHeader(F("Cache-Control"), F("no-cache, no-store, must-revalidate")); + HTTPSend(page); + return; + } #ifdef WM_DEBUG_LEVEL String requestinfo = "SERVER_REQUEST\n----------------\n"; requestinfo += "URI: "; @@ -1890,6 +2275,52 @@ void WiFiManager::handleWifiSave() { if(_paramsInWifi) doParamSave(); + // ---- AP+STA provisioning mode: start a non-blocking STA connection ---- + // When _keepAPDuringSTAConnect is true we start the WiFi connection + // immediately without blocking, keep the AP + web server running, and + // let the frontend poll /status for the result. + if(_keepAPDuringSTAConnect && _ssid != "") { + #ifdef WM_DEBUG_LEVEL + DEBUG_WM(WM_DEBUG_VERBOSE,F("Provisioning: starting non-blocking STA connect to:"),_ssid); + #endif + // Reset provisioning state + _provisioningState = WM_PROV_CONNECTING; + _provisioningConnecting = true; + _provisioningError = ""; + _apShutdownPending = false; + _startconn = millis(); + _ipWaitStart = 0; // reset DHCP-wait timer for new attempt + + setLEDState(WM_LED_CONNECTING); // signal LED: connecting + + // Apply static IP config if set + setSTAConfig(); + + // Start STA connection without persistent-save (credentials are saved on + // success by saveWiFiCredentials() inside checkProvisioningState()). + WiFi_enableSTA(true, storeSTAmode); + WiFi.persistent(false); + WiFi.begin(_ssid.c_str(), _pass.c_str()); + WiFi.persistent(false); + + // Return the provisioning status page which polls /status via JS + // Replace custom SVG tokens before sending. + String page = getHTTPHead(FPSTR(S_titlewifisaved), FPSTR(C_wifi)); + String provPage = FPSTR(HTTP_SAVED_PROVISIONING); + provPage.replace(F("{svgC}"), _customConnectingSVG ? _customConnectingSVG : ""); + provPage.replace(F("{svgS}"), _customSuccessSVG ? _customSuccessSVG : ""); + provPage.replace(F("{svgF}"), _customFailureSVG ? _customFailureSVG : ""); + page += provPage; + if(_showBack) page += FPSTR(HTTP_BACKBTN); + page += FPSTR(HTTP_NAV_BOTTOM); + page += getHTTPEnd(); + HTTPSend(page); + #ifdef WM_DEBUG_LEVEL + DEBUG_WM(WM_DEBUG_DEV,F("Sent provisioning save page")); + #endif + return; // do NOT set connect=true; state machine in processConfigPortal handles the rest + } + String page; if(_ssid == ""){ @@ -1929,6 +2360,7 @@ void WiFiManager::handleParamSave() { String page = getHTTPHead(FPSTR(S_titleparamsaved), FPSTR(C_param)); // @token titleparamsaved page += FPSTR(HTTP_PARAMSAVED); if(_showBack) page += FPSTR(HTTP_BACKBTN); + page += FPSTR(HTTP_NAV_BOTTOM); page += getHTTPEnd(); HTTPSend(page); @@ -2086,6 +2518,7 @@ void WiFiManager::handleInfo() { if(_showInfoErase) page += FPSTR(HTTP_ERASEBTN); if(_showBack) page += FPSTR(HTTP_BACKBTN); page += FPSTR(HTTP_HELP); + page += FPSTR(HTTP_NAV_BOTTOM); page += getHTTPEnd(); HTTPSend(page); @@ -3254,6 +3687,59 @@ void WiFiManager::setParamsPage(bool enable){ // GETTERS +// --- AP+STA Provisioning setters/getters --- + +/** + * setKeepAPDuringSTAConnect + * When true the config portal runs in AP+STA mode: the SoftAP remains active + * during and after the STA connection attempt. Credentials are saved only on + * a successful connection. The AP is shut down after _apShutdownDelayMs ms. + * Default: false (legacy blocking behaviour unchanged). + */ +void WiFiManager::setKeepAPDuringSTAConnect(bool keep) { + _keepAPDuringSTAConnect = keep; +} + +/** + * setAPShutdownDelay + * Set the number of milliseconds to keep the SoftAP running after a + * successful STA connection before it is shut down (default: 10 000 ms). + * Set to 0 to shut down immediately on connect. + */ +void WiFiManager::setAPShutdownDelay(unsigned long ms) { + _apShutdownDelayMs = ms; +} + +/** + * setDetailedFailureReasons + * When true the /status JSON endpoint maps low-level WL status codes to + * human-readable strings (e.g. "Wrong password", "Network not found"). + * Default: false. + */ +void WiFiManager::setDetailedFailureReasons(bool enable) { + _detailedFailureReasons = enable; +} + +/** + * setCaptivePortalCompatibility + * When true (default) the portal registers extra HTTP handlers for the OS + * captive portal probes used by Android (/generate_204), iOS/macOS + * (/hotspot-detect.html) and Windows (/ncsi.txt, /connecttest.txt). + * These redirects cause the OS to automatically open the captive-portal UI. + * HTTPS traffic is never intercepted. + */ +void WiFiManager::setCaptivePortalCompatibility(bool enable) { + _captivePortalCompat = enable; +} + +/** + * getProvisioningState + * Returns the current provisioning state as a wm_provstate_t enum value. + */ +wm_provstate_t WiFiManager::getProvisioningState() { + return _provisioningState; +} + /** * get config portal AP SSID * @since 0.0.1 @@ -3882,6 +4368,18 @@ String WiFiManager::WiFi_psk(bool persistent) const { #endif WiFi.reconnect(); #endif + // LED: show FAILED on disconnect, but ignore the spurious disconnect that + // WiFi.begin(connect=false) / AP teardown fires during a successful provisioning + // save – _provisioningState is CONNECTED from the moment we confirm the STA link + // until the portal fully closes. + if(_provisioningState != WM_PROV_CONNECTED) { + setLEDState(WM_LED_FAILED); + } + } + else if(event == ARDUINO_EVENT_WIFI_STA_GOT_IP){ + // LED: STA obtained an IP – this covers autonomous reconnects as well as + // initial provisioning, overriding whatever state the LED was in. + setLEDState(WM_LED_CONNECTED); } else if(event == ARDUINO_EVENT_WIFI_SCAN_DONE && _asyncScan){ uint16_t scans = WiFi.scanComplete(); @@ -3893,6 +4391,24 @@ String WiFiManager::WiFi_psk(bool persistent) const { void WiFiManager::WiFi_autoReconnect(){ #ifdef ESP8266 WiFi.setAutoReconnect(_wifiAutoReconnect); + // Register persistent WiFi event handlers so the LED is updated whenever + // the network connects or disconnects autonomously (router restart, + // device moves in/out of range, password changed on router, etc.). + // The WiFiEventHandler objects are stored as members to keep them alive. + if(!_wifiGotIPHandler) { + _wifiGotIPHandler = WiFi.onStationModeGotIP([this](const WiFiEventStationModeGotIP&) { + setLEDState(WM_LED_CONNECTED); + }); + } + if(!_wifiDisconnectedHandler) { + _wifiDisconnectedHandler = WiFi.onStationModeDisconnected([this](const WiFiEventStationModeDisconnected&) { + // Ignore the spurious disconnect fired by WiFi.begin(connect=false) / AP teardown + // during a successful provisioning save. + if(_provisioningState != WM_PROV_CONNECTED) { + setLEDState(WM_LED_FAILED); + } + }); + } #elif defined(ESP32) // if(_wifiAutoReconnect){ // @todo move to seperate method, used for event listener now @@ -4051,3 +4567,156 @@ void WiFiManager::handleUpdateDone() { } #endif + +// --------------------------------------------------------------------------- +// LED behaviour +// --------------------------------------------------------------------------- + +/** + * setLEDCallback + * Register a function to be called whenever the LED state changes. + * The callback receives a wm_ledstate_t: + * WM_LED_OFF – LED should be turned off (timeout elapsed) + * WM_LED_NOWIFI – No WiFi configured (suggest: Orange, solid) + * WM_LED_CONNECTED – WiFi connected (suggest: Green, solid) + * WM_LED_FAILED – Connection failed (suggest: Red, solid) + * WM_LED_CONNECTING – Connecting in progress (suggest: Blue, pulsing) + */ +void WiFiManager::setLEDCallback(std::function func) { + _ledcallback = func; +} + +/** Set how long (ms) the LED stays on for the "no WiFi configured" state. 0 = infinite. */ +void WiFiManager::setLEDTimeoutNoWifi(unsigned long ms) { + _ledTimeoutNoWifi = ms; +} + +/** Set how long (ms) the LED stays on after a successful WiFi connection. 0 = infinite. */ +void WiFiManager::setLEDTimeoutConnected(unsigned long ms) { + _ledTimeoutConnected = ms; +} + +/** Set how long (ms) the LED stays on after a failed connection attempt. 0 = infinite. */ +void WiFiManager::setLEDTimeoutFailed(unsigned long ms) { + _ledTimeoutFailed = ms; +} + +/** Set how long (ms) the LED stays on while a connection attempt is in progress. 0 = infinite. */ +void WiFiManager::setLEDTimeoutConnecting(unsigned long ms) { + _ledTimeoutConnecting = ms; +} + +/** + * setLEDState (private) + * Transition to a new LED state, reset the timeout timer, and invoke the + * user callback. Calling with the same state that is already active is a + * no-op (avoids flooding the callback on every processConfigPortal tick). + */ +void WiFiManager::setLEDState(wm_ledstate_t state) { + if(_ledcallback == nullptr) return; + if(state == _ledCurrentState) return; + _ledCurrentState = state; + _ledStateStart = millis(); + _ledcallback(state); +} + +/** + * checkLEDTimeout (private) + * If the current LED state has been active for longer than its configured + * timeout (and the timeout is > 0), transition to WM_LED_OFF. + * Called from processConfigPortal() on every iteration. + */ +void WiFiManager::checkLEDTimeout() { + if(_ledcallback == nullptr) return; + if(_ledCurrentState == WM_LED_OFF) return; + + unsigned long timeout = 0; + switch(_ledCurrentState) { + case WM_LED_NOWIFI: timeout = _ledTimeoutNoWifi; break; + case WM_LED_CONNECTED: timeout = _ledTimeoutConnected; break; + case WM_LED_FAILED: timeout = _ledTimeoutFailed; break; + case WM_LED_CONNECTING: timeout = _ledTimeoutConnecting; break; + default: return; + } + + if(timeout > 0 && (millis() - _ledStateStart) >= timeout) { + _ledCurrentState = WM_LED_OFF; + _ledcallback(WM_LED_OFF); + } +} + +/** + * syncLEDState (private) + * Should be called regularly from loop() via process() and from processConfigPortal(). + * Two jobs: + * 1. Always run checkLEDTimeout() so the per-state timer fires even when + * the config portal is not active (fixes "OFF never triggered" after autoConnect). + * 2. Once per second, correct "stuck on CONNECTING" if WiFi is fully connected + * (WL_CONNECTED + valid IP assigned). This covers the case where the + * GOT_IP event callback was missed. + * + * Intentionally NOT done here: + * - Converting CONNECTED → FAILED when WiFi.status() is not WL_CONNECTED. + * WiFi.status() is not a stable signal: DHCP renewal, a background scan + * (ESP8266), or normal beacon-miss recovery can produce a brief + * WL_DISCONNECTED reading even on a healthy link. Polling that state and + * immediately calling setLEDState(FAILED) causes false-positive red flashes. + * Real disconnects are already handled by the event callbacks registered in + * WiFi_autoReconnect() (onStationModeDisconnected / ARDUINO_EVENT_WIFI_STA_DISCONNECTED). + * - Correcting FAILED/NOWIFI → CONNECTED on an autonomous reconnect. + * Those transitions are also handled by the GOT_IP event callback. + */ +void WiFiManager::syncLEDState() { + if(_ledcallback == nullptr) return; + + // Always check timeout so WM_LED_CONNECTED (and others) time out correctly + // even when the config portal loop is not running. + checkLEDTimeout(); + + // Rate-limit the WiFi-status poll to once per second. + if(millis() - _ledLastPoll < 1000) return; + _ledLastPoll = millis(); + + // Only correct "stuck on blue": if we are still showing CONNECTING but WiFi + // has actually fully connected (status AND a valid IP have both settled), + // advance to CONNECTED. + // We require a non-zero localIP so we do not fire prematurely during the + // DHCP-assignment window that checkProvisioningState() waits through. + if(_ledCurrentState == WM_LED_CONNECTING + && WiFi.status() == WL_CONNECTED + && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { + setLEDState(WM_LED_CONNECTED); + } +} + + + +/** + * setCustomConnectingSVG + * Set custom SVG (or any HTML) to display on the provisioning status page + * while a WiFi connection attempt is in progress. + * Pass NULL to remove. + */ +void WiFiManager::setCustomConnectingSVG(const char* svg) { + _customConnectingSVG = svg; +} + +/** + * setCustomSuccessSVG + * Set custom SVG (or any HTML) to display on the provisioning status page + * when the WiFi connection succeeds. + * Pass NULL to remove. + */ +void WiFiManager::setCustomSuccessSVG(const char* svg) { + _customSuccessSVG = svg; +} + +/** + * setCustomFailureSVG + * Set custom SVG (or any HTML) to display on the provisioning status page + * when the WiFi connection fails. + * Pass NULL to remove. + */ +void WiFiManager::setCustomFailureSVG(const char* svg) { + _customFailureSVG = svg; +} diff --git a/WiFiManager.h b/WiFiManager.h index 744e65a3..76d5fa4a 100644 --- a/WiFiManager.h +++ b/WiFiManager.h @@ -255,6 +255,25 @@ class WiFiManagerParameter { WM_DEBUG_MAX = 5 // MAX extra dev auditing, var dumps etc (MAX+1 will print timing,mem and frag info) } wm_debuglevel_t; + // Provisioning connection state (AP+STA mode) + typedef enum { + WM_PROV_IDLE = 0, // no active provisioning + WM_PROV_SCANNING = 1, // scanning for networks + WM_PROV_CONNECTING = 2, // STA connection in progress + WM_PROV_CONNECTED = 3, // STA connected, waiting for AP shutdown delay + WM_PROV_FAILED = 4, // STA connection failed + WM_PROV_RETRYING = 5 // retrying STA connection + } wm_provstate_t; + + // LED indicator state for developer-supplied LED callback + typedef enum { + WM_LED_OFF = 0, // LED off (timeout elapsed) + WM_LED_NOWIFI = 1, // No wifi configured – suggest Orange (infinite by default) + WM_LED_CONNECTED = 2, // WiFi connected – suggest Green (15 s by default) + WM_LED_FAILED = 3, // Connection failed – suggest Red (infinite by default) + WM_LED_CONNECTING = 4 // Trying to connect – suggest Blue pulsing (5 s by default) + } wm_ledstate_t; + class WiFiManager { public: @@ -514,6 +533,49 @@ class WiFiManager // get hostname helper String getWiFiHostname(); + // --- AP+STA Provisioning options --- + + // if true, keep AP active while STA connection attempt is in progress (default false) + void setKeepAPDuringSTAConnect(bool keep); + + // set the delay in ms before the AP is shut down after a successful STA connection (default 10000) + void setAPShutdownDelay(unsigned long ms); + + // if true, map low-level errors to descriptive messages in /status JSON (default false) + void setDetailedFailureReasons(bool enable); + + // if true (default), register OS captive-portal probe handlers (/generate_204, /hotspot-detect.html, /ncsi.txt) + void setCaptivePortalCompatibility(bool enable); + + // get the current provisioning state (see wm_provstate_t) + wm_provstate_t getProvisioningState(); + + // --- LED behaviour --- + + // Set a callback invoked whenever the LED state changes. + // The callback receives a wm_ledstate_t value; WM_LED_OFF means the + // timeout has elapsed and the LED should be turned off. + void setLEDCallback(std::function func); + + // Per-state LED timeout in milliseconds. 0 = stay on indefinitely. + // Defaults: NoWifi=0 (infinite), Connected=15000 (15 s), + // Failed=0 (infinite), Connecting=5000 (5 s). + void setLEDTimeoutNoWifi(unsigned long ms); + void setLEDTimeoutConnected(unsigned long ms); + void setLEDTimeoutFailed(unsigned long ms); + void setLEDTimeoutConnecting(unsigned long ms); + + // --- Custom animated SVGs for the provisioning status page --- + + // Set a custom SVG (or any HTML) shown while connecting to WiFi. + void setCustomConnectingSVG(const char* svg); + + // Set a custom SVG (or any HTML) shown on a successful connection. + void setCustomSuccessSVG(const char* svg); + + // Set a custom SVG (or any HTML) shown when the connection failed. + void setCustomFailureSVG(const char* svg); + std::unique_ptr dnsServer; @@ -545,6 +607,7 @@ class WiFiManager unsigned long _webPortalAccessed = 0; // ms last web access time uint8_t _lastconxresult = WL_IDLE_STATUS; // store last result when doing connect operations int _numNetworks = 0; // init index for numnetworks wifiscans + bool _scanFailed = false; // true when last sync scan returned WIFI_SCAN_FAILED unsigned long _lastscan = 0; // ms for timing wifi scans unsigned long _startscan = 0; // ms for timing wifi scans unsigned long _startconn = 0; // ms for timing wifi connects @@ -581,13 +644,34 @@ class WiFiManager // https://github.com/tzapu/WiFiManager/issues/1067 bool _allowExit = true; // allow exit in nonblocking, else user exit/abort calls will be ignored including cptimeout + // AP+STA provisioning flow options + bool _keepAPDuringSTAConnect = false; // keep AP running while STA connection is in progress + unsigned long _apShutdownDelayMs = 10000; // ms to keep AP alive after successful STA connect before shutting down + bool _detailedFailureReasons = false; // map low-level errors to human-readable strings in /status + bool _captivePortalCompat = true; // register OS captive portal probe handlers (generate_204 etc.) + + // provisioning state machine (used when _keepAPDuringSTAConnect is true) + wm_provstate_t _provisioningState = WM_PROV_IDLE; + String _provisioningError = ""; + bool _provisioningConnecting = false; // true while non-blocking STA connect is running + unsigned long _apShutdownDeadline = 0; // millis() deadline for AP shutdown + bool _apShutdownPending = false; // true when waiting for AP shutdown delay + unsigned long _ipWaitStart = 0; // millis() when WL_CONNECTED first seen with 0.0.0.0 IP + #ifdef ESP32 wifi_event_id_t wm_event_id = 0; static uint8_t _lastconxresulttmp; // tmp var for esp32 callback #endif + #ifdef ESP8266 + // WiFiEventHandler objects must be kept alive for the duration of the + // callback registration; storing them as members achieves this. + WiFiEventHandler _wifiGotIPHandler; + WiFiEventHandler _wifiDisconnectedHandler; + #endif + #ifndef WL_STATION_WRONG_PASSWORD - uint8_t WL_STATION_WRONG_PASSWORD = 7; // @kludge define a WL status for wrong password + static constexpr uint8_t WL_STATION_WRONG_PASSWORD = 7; // @kludge define a WL status for wrong password #endif // parameter options @@ -619,6 +703,21 @@ class WiFiManager String _bodyClass = ""; // class to add to body String _title = FPSTR(S_brand); // app title - default WiFiManager + // Custom SVG/HTML slots for provisioning status page + const char* _customConnectingSVG = nullptr; // shown while connecting + const char* _customSuccessSVG = nullptr; // shown on successful connection + const char* _customFailureSVG = nullptr; // shown on connection failure + + // LED state callback and per-state timeouts + std::function _ledcallback = nullptr; + unsigned long _ledTimeoutNoWifi = 0; // 0 = infinite (orange – no wifi configured) + unsigned long _ledTimeoutConnected = 15000; // 15 s (green – connected) + unsigned long _ledTimeoutFailed = 0; // 0 = infinite (red – failed) + unsigned long _ledTimeoutConnecting = 5000; // 5 s (blue pulsing – connecting) + wm_ledstate_t _ledCurrentState = WM_LED_OFF; + unsigned long _ledStateStart = 0; // millis() when current LED state was set + unsigned long _ledLastPoll = 0; // millis() of last 1-second LED reconciliation + // internal options // wifiscan notes @@ -680,6 +779,17 @@ class WiFiManager uint8_t waitForConnectResult(uint32_t timeout); void updateConxResult(uint8_t status); + // provisioning state machine helpers + uint8_t checkProvisioningState(); // poll STA status; returns WL_IDLE_STATUS or WL_CONNECTED + bool saveWiFiCredentials(String ssid, String pass); // persist credentials only on success + String getProvisioningStateStr(); // convert _provisioningState to string for /status JSON + String getProvisioningFailureReason(uint8_t status); // map WL status to human-readable string + + // LED state helpers + void setLEDState(wm_ledstate_t state); // set LED state and invoke callback + void checkLEDTimeout(); // turn LED off when per-state timeout elapses + void syncLEDState(); // call from loop(): check timeout + reconcile with WiFi status every 1 s + // webserver handlers public: void handleNotFound(); @@ -691,6 +801,11 @@ class WiFiManager void handleInfo(); void handleReset(); + // OS captive portal probe handlers + void handleCaptivePortal204(); // Android /generate_204 + void handleCaptivePortalHotspot(); // iOS/macOS /hotspot-detect.html + void handleCaptivePortalNcsi(); // Windows /ncsi.txt + void handleExit(); void handleClose(); // void handleErase(); diff --git a/examples/Provisioning/Provisioning.ino b/examples/Provisioning/Provisioning.ino new file mode 100644 index 00000000..b7cbea12 --- /dev/null +++ b/examples/Provisioning/Provisioning.ino @@ -0,0 +1,242 @@ +/** + * WiFiManager – AP+STA Provisioning Flow Example + * ================================================ + * Demonstrates the non-blocking AP+STA provisioning flow: + * + * 1. On first boot (or when stored credentials fail) the device starts a + * SoftAP so users can connect and reach the captive-portal setup page. + * 2. The OS automatically opens the portal thanks to the built-in captive- + * portal probe handlers (Android /generate_204, iOS /hotspot-detect.html, + * Windows /ncsi.txt). + * 3. When the user selects a network and submits a password the device starts + * a STA connection attempt WITHOUT tearing down the AP or rebooting. + * 4. The browser polls /status (JSON) and displays real-time feedback + * (connecting → connected / failed). + * 5. On success: credentials are saved, the IP address is shown, and the AP + * shuts down after a configurable delay (default 15 s). + * 6. On failure: a descriptive error is shown and the user can retry – the AP + * stays up and no credentials are saved. + * 7. On subsequent boots the stored credentials are tried first; if they fail + * the provisioning portal starts again automatically. + * + * LED feedback (NeoPixel RGB on pin 38) + * --------------------------------------- + * Orange – No WiFi configured (stays on indefinitely) + * Blue – Connecting (pulsing, 5 s) + * Green – Connected (15 s then off) + * Red – Connection failed (stays on indefinitely) + * + * Custom animated SVGs on the status page + * ---------------------------------------- + * Connecting : spinning ring (blue) + * Success : animated check mark (green) + * Failure : pulsing X (red) + * + * Works on ESP32 and ESP8266. + * Library: https://github.com/tzapu/WiFiManager + */ + +#include +#include + +// ---- NeoPixel configuration ---- +#define LED_PIN 38 +#define LED_COUNT 1 +Adafruit_NeoPixel pixel(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800); + +// ---- user-configurable options ---- +static const char AP_NAME[] = "ESP-Setup"; +static const char AP_PASS[] = ""; // leave empty for open AP +static const unsigned long AP_SHUTDOWN_DELAY_MS = 15000; // 15 s +// ----------------------------------- + +WiFiManager wm; +bool provisioningDone = false; + +// ----- animated SVG strings ----- + +// Connecting: spinning blue ring +static const char SVG_CONNECTING[] PROGMEM = + "" + "" + "" + "" + "" + ""; + +// Success: animated check-mark in a green circle +static const char SVG_SUCCESS[] PROGMEM = + "" + "" + "" + "" + "" + ""; + +// Failure: pulsing red X +static const char SVG_FAILURE[] PROGMEM = + "" + "" + "" + "" + "" + "" + ""; + +// ----- LED helpers ----- + +// Pulse blue: brightness oscillates between ~20% and 100% driven by millis() +void updateConnectingPulse() { + float t = (millis() % 1000) / 1000.0f; + uint8_t brightness = (uint8_t)(51 + 204 * (0.5f + 0.5f * sin(t * 2.0f * 3.14159f))); + pixel.setPixelColor(0, pixel.Color(0, 0, brightness)); + pixel.show(); +} + +void ledSolid(uint8_t r, uint8_t g, uint8_t b) { + pixel.setPixelColor(0, pixel.Color(r, g, b)); + pixel.show(); +} + +void ledOff() { + pixel.clear(); + pixel.show(); +} + +// Track whether we're in connecting-pulse mode so loop() can keep updating it +bool ledPulsing = false; + +// LED state callback – invoked by WiFiManager on every state change +void onLEDState(wm_ledstate_t state) { + ledPulsing = false; + switch (state) { + case WM_LED_NOWIFI: + ledSolid(255, 100, 0); // Orange: no WiFi configured + Serial.println(F("[LED] Orange – no WiFi configured")); + break; + case WM_LED_CONNECTING: + ledPulsing = true; // Blue pulsing: will be updated in loop() + Serial.println(F("[LED] Blue pulsing – connecting")); + break; + case WM_LED_CONNECTED: + ledSolid(0, 200, 0); // Green: connected + Serial.println(F("[LED] Green – connected")); + break; + case WM_LED_FAILED: + ledSolid(220, 0, 0); // Red: connection failed + Serial.println(F("[LED] Red – connection failed")); + break; + case WM_LED_OFF: + ledOff(); // Timeout elapsed: LED off + Serial.println(F("[LED] Off")); + break; + } +} + +void setup() { + Serial.begin(115200); + Serial.println(F("\n\n=== WiFiManager AP+STA Provisioning Example ===")); + + // Init NeoPixel + pixel.begin(); + pixel.setBrightness(80); // 0-255; 80 is ~31% – comfortable for indoor use + ledOff(); + + // Optional: uncomment to wipe saved credentials and force provisioning + // wm.resetSettings(); + + // ---- AP+STA provisioning options ---- + wm.setKeepAPDuringSTAConnect(true); // Keep AP while STA connects + wm.setAPShutdownDelay(AP_SHUTDOWN_DELAY_MS); // Shut down AP 15 s after success + wm.setDetailedFailureReasons(true); // Show "Wrong password" etc. + wm.setCaptivePortalCompatibility(true); // Android/iOS/Windows auto-open (default) + wm.setShowPassword(true); + + // ---- LED callback ---- + wm.setLEDCallback(onLEDState); + wm.setLEDTimeoutConnected(15000); // Green stays on for 15 s, then off + wm.setLEDTimeoutConnecting(5000); // Blue pulsing shown for max 5 s even if still connecting + wm.setLEDTimeoutNoWifi(0); // Orange stays on indefinitely + wm.setLEDTimeoutFailed(0); // Red stays on indefinitely + + // ---- Custom animated SVGs on the provisioning status page ---- + wm.setCustomConnectingSVG(SVG_CONNECTING); + wm.setCustomSuccessSVG(SVG_SUCCESS); + wm.setCustomFailureSVG(SVG_FAILURE); + + // ---- general portal options ---- + wm.setConfigPortalBlocking(false); // Non-blocking so loop() keeps running + wm.setConfigPortalTimeout(0); // 0 = never time out while user is present + + // Callback: called when SoftAP is started + wm.setAPCallback([](WiFiManager *w) { + Serial.print(F("AP started: ")); + Serial.println(w->getConfigPortalSSID()); + Serial.print(F("AP IP: ")); + Serial.println(WiFi.softAPIP()); + }); + + // Callback: called when credentials are saved and STA is confirmed connected + wm.setSaveConfigCallback([]() { + Serial.println(F("Credentials saved! STA connected.")); + }); + + // Try stored credentials; on failure start provisioning portal + if (wm.autoConnect(AP_NAME, AP_PASS[0] ? AP_PASS : nullptr)) { + // Stored credentials worked – device is already connected + Serial.print(F("Auto-connected! IP: ")); + Serial.println(WiFi.localIP()); + provisioningDone = true; + } else { + Serial.println(F("No stored credentials (or they failed). Provisioning portal running.")); + Serial.println(F("Connect to the AP and open the captive portal to configure WiFi.")); + } +} + +void loop() { + // Keep the blue pulse animation alive while connecting + if (ledPulsing) { + updateConnectingPulse(); + } + + // Drive the non-blocking portal (processes DNS, HTTP, and the provisioning + // state machine). Returns true once STA is connected AND the AP has been + // shut down (i.e. provisioning is complete). + if (!provisioningDone) { + if (wm.process()) { + provisioningDone = true; + Serial.print(F("Provisioning complete! STA IP: ")); + Serial.println(WiFi.localIP()); + } + + // You can also inspect the state directly: + wm_provstate_t state = wm.getProvisioningState(); + static wm_provstate_t lastState = WM_PROV_IDLE; + if (state != lastState) { + lastState = state; + switch (state) { + case WM_PROV_CONNECTING: + Serial.println(F("[Provisioning] Connecting to STA...")); + break; + case WM_PROV_CONNECTED: + Serial.print(F("[Provisioning] STA connected, IP: ")); + Serial.println(WiFi.localIP()); + Serial.println(F("AP will shut down shortly.")); + break; + case WM_PROV_FAILED: + Serial.println(F("[Provisioning] Connection failed. Portal still open.")); + break; + default: + break; + } + } + } + + // ---- put your normal application code here ---- + // It runs concurrently while the provisioning portal is active. +} diff --git a/wm_strings_en.h b/wm_strings_en.h index 137d2e99..d9aba7b6 100644 --- a/wm_strings_en.h +++ b/wm_strings_en.h @@ -34,16 +34,17 @@ const char HTTP_SCRIPT[] PROGMEM = ""; // @todo add button states, disable on click , show ack , spinner etc + const char HTTP_HEAD_END[] PROGMEM = "
"; // {c} = _bodyclass // example of embedded logo, base64 encoded inline, No styling here // const char HTTP_ROOT_MAIN[] PROGMEM = "

{v}

WiFiManager

"; const char HTTP_ROOT_MAIN[] PROGMEM = "

{t}

{v}

"; const char * const HTTP_PORTAL_MENU[] PROGMEM = { -"

\n", // MENU_WIFI +"

\n", // MENU_WIFI "

\n", // MENU_WIFINOSCAN "

\n", // MENU_INFO "

\n",//MENU_PARAM @@ -59,40 +60,161 @@ const char * const HTTP_PORTAL_MENU[] PROGMEM = { const char HTTP_PORTAL_OPTIONS[] PROGMEM = ""; const char HTTP_ITEM_QI[] PROGMEM = ""; // rssi icons const char HTTP_ITEM_QP[] PROGMEM = "
{r}%
"; // rssi percentage {h} = hidden showperc pref -const char HTTP_ITEM[] PROGMEM = "
{v}{qi}{qp}
"; // {q} = HTTP_ITEM_QI, {r} = HTTP_ITEM_QP +const char HTTP_ITEM[] PROGMEM = "
{v}{qi}{qp}
"; // {q} = HTTP_ITEM_QI, {r} = HTTP_ITEM_QP // const char HTTP_ITEM[] PROGMEM = "
{v} {R} {r}% {q} {e}
"; // test all tokens const char HTTP_FORM_START[] PROGMEM = "
"; -const char HTTP_FORM_WIFI[] PROGMEM = "

"; +const char HTTP_FORM_WIFI[] PROGMEM = "

"; const char HTTP_FORM_WIFI_END[] PROGMEM = ""; const char HTTP_FORM_STATIC_HEAD[] PROGMEM = "

"; -const char HTTP_FORM_END[] PROGMEM = "

"; +const char HTTP_FORM_END[] PROGMEM = "
"; const char HTTP_FORM_LABEL[] PROGMEM = ""; const char HTTP_FORM_PARAM_HEAD[] PROGMEM = "

"; const char HTTP_FORM_PARAM[] PROGMEM = "
\n"; // do not remove newline! -const char HTTP_SCAN_LINK[] PROGMEM = "
"; -const char HTTP_SAVED[] PROGMEM = "
Saving Credentials
Trying to connect ESP to network.
If it fails reconnect to AP to try again
"; +const char HTTP_SCAN_LINK[] PROGMEM = "
"; +const char HTTP_SAVED[] PROGMEM = "
Connecting to WiFi network...
If the connection fails, come back to this page to try again.
"; const char HTTP_PARAMSAVED[] PROGMEM = "
Saved
"; +// Provisioning mode save page: stays open and polls /status with JavaScript +// {svgC} = custom connecting SVG (replaced server-side), {svgS} = success SVG, {svgF} = failure SVG +const char HTTP_SAVED_PROVISIONING[] PROGMEM = + "" + "" + "" + "
 Connecting…
Please wait
" + "
" + ""; const char HTTP_END[] PROGMEM = "
"; const char HTTP_ERASEBTN[] PROGMEM = "
"; const char HTTP_UPDATEBTN[] PROGMEM = "
"; const char HTTP_BACKBTN[] PROGMEM = "

"; -const char HTTP_STATUS_ON[] PROGMEM = "
Connected to {v}
with IP {i}
"; -const char HTTP_STATUS_OFF[] PROGMEM = "
Not connected to {v}{r}
"; // {c=class} {v=ssid} {r=status_off} -const char HTTP_STATUS_OFFPW[] PROGMEM = "
Authentication failure"; // STATION_WRONG_PASSWORD, no eps32 -const char HTTP_STATUS_OFFNOAP[] PROGMEM = "
AP not found"; // WL_NO_SSID_AVAIL -const char HTTP_STATUS_OFFFAIL[] PROGMEM = "
Could not connect"; // WL_CONNECT_FAILED -const char HTTP_STATUS_NONE[] PROGMEM = "
No AP set
"; +// Bottom navigation bar shown on WiFi-setup and info pages +const char HTTP_NAV_BOTTOM[] PROGMEM = + ""; + +// Live status polling script – polls /status every 6 s and updates the element +// with id='wm-live-status'. Shows a spinner immediately and reveals failure +// messages only after several polls so a brief disconnect is not alarming. +const char HTTP_STATUS_LIVE_SCRIPT[] PROGMEM = + ""; + +const char HTTP_STATUS_ON[] PROGMEM = "
Connected to {v}
IP: {i}
"; +const char HTTP_STATUS_OFF[] PROGMEM = "
Not connected to {v}{r}
"; // {c=class} {v=ssid} {r=status_off} +const char HTTP_STATUS_OFFPW[] PROGMEM = "
Wrong password"; // STATION_WRONG_PASSWORD +const char HTTP_STATUS_OFFNOAP[] PROGMEM = "
Network not found"; // WL_NO_SSID_AVAIL +const char HTTP_STATUS_OFFFAIL[] PROGMEM = "
Check your settings"; // WL_CONNECT_FAILED +const char HTTP_STATUS_NONE[] PROGMEM = "
No WiFi network configured
"; const char HTTP_BR[] PROGMEM = "
"; const char HTTP_STYLE[] PROGMEM = ""; #ifndef WM_NOHELP @@ -234,7 +404,7 @@ const char S_NA[] PROGMEM = "Unknown"; const char S_passph[] PROGMEM = "********"; const char S_titlewifisaved[] PROGMEM = "Credentials saved"; const char S_titlewifisettings[] PROGMEM = "Settings saved"; -const char S_titlewifi[] PROGMEM = "Config ESP"; +const char S_titlewifi[] PROGMEM = "WiFi Setup"; const char S_titleinfo[] PROGMEM = "Info"; const char S_titleparam[] PROGMEM = "Setup"; const char S_titleparamsaved[] PROGMEM = "Setup saved"; @@ -244,11 +414,12 @@ const char S_titleerase[] PROGMEM = "Erase"; const char S_titleclose[] PROGMEM = "Close"; const char S_options[] PROGMEM = "options"; const char S_nonetworks[] PROGMEM = "No networks found. Refresh to scan again."; +const char S_scanfailed[] PROGMEM = "Scan failed. Tap refresh to try again."; const char S_staticip[] PROGMEM = "Static IP"; const char S_staticgw[] PROGMEM = "Static gateway"; const char S_staticdns[] PROGMEM = "Static DNS"; const char S_subnet[] PROGMEM = "Subnet"; -const char S_exiting[] PROGMEM = "Exiting"; +const char S_exiting[] PROGMEM = "
 Closing setup…
You can close this window
"; const char S_resetting[] PROGMEM = "Module will reset in a few seconds."; const char S_closing[] PROGMEM = "You can close the page, portal will continue to run"; const char S_error[] PROGMEM = "An error occured";