WiFiManager: fragment templates, handler refactor, and tests.

Split reusable HTML into Fragments, Message, and Param headers; route
more pages through DFTE streaming. Update handlers, WiFi polling JS,
and configuration/scanning tests; add template rendering coverage.
This commit is contained in:
2026-04-18 00:23:57 +10:00
parent 69aea5ce35
commit 9401c20df6
20 changed files with 1988 additions and 1044 deletions
+101 -7
View File
@@ -29,7 +29,6 @@
// #define WM_ERASE_NVS // esp32 erase(true) will erase NVS
// #define WM_RTC // esp32 info page will include reset reasons
// #define WM_JSTEST // build flag for enabling js xhr tests
// #define WIFI_MANAGER_OVERRIDE_STRINGS // build flag for using own strings include
#ifdef ARDUINO_ESP8266_RELEASE_2_3_0
@@ -203,6 +202,49 @@ class WiFiManager
public:
// Forward declare nested class - defined later after WM_WebServer is available
class WiFiManagerRequestArgs;
enum wm_scan_state_t : uint8_t {
WM_SCAN_IDLE = 0,
WM_SCAN_QUEUED,
WM_SCAN_RUNNING,
WM_SCAN_COMPLETE,
WM_SCAN_FAILED,
WM_SCAN_TIMEOUT,
};
enum wm_scan_schedule_reason_t : uint8_t {
WM_SCAN_SCHEDULE_NONE = 0,
WM_SCAN_SCHEDULE_PRELOAD,
WM_SCAN_SCHEDULE_USER_REFRESH,
WM_SCAN_SCHEDULE_STALE_CACHE,
WM_SCAN_SCHEDULE_UI_RESUME,
};
struct WiFiScanNetwork {
String ssid;
int32_t rssi = 0;
uint8_t encType = 0;
};
struct WiFiScanRuntimeState {
wm_scan_state_t state = WM_SCAN_IDLE;
bool resultsValid = false;
bool schedulePending = false;
bool forceRefresh = false;
bool completionPending = false;
unsigned long requestedAt = 0;
unsigned long startedAt = 0;
unsigned long finishedAt = 0;
unsigned long timeoutMs = 15000;
unsigned long minRestartIntervalMs = 2000;
uint32_t generation = 0;
uint32_t runningGeneration = 0;
uint32_t completionGeneration = 0;
int lastScanResult = WIFI_SCAN_FAILED;
int completionResult = WIFI_SCAN_FAILED;
int visibleNetworkCount = 0;
wm_scan_schedule_reason_t scheduledReason = WM_SCAN_SCHEDULE_NONE;
};
WiFiManager(Print& consolePort);
WiFiManager();
@@ -229,6 +271,15 @@ class WiFiManager
// Run webserver processing - must be called periodically when config portal is active
boolean process();
// async scan state and cached result accessors
void requestAsyncScan(bool forceRefresh = false);
const WiFiScanRuntimeState& getScanSnapshot() const { return _scan; }
wm_scan_state_t getScanState() const { return _scan.state; }
bool isScanRunning() const { return _scan.state == WM_SCAN_RUNNING || _scan.state == WM_SCAN_QUEUED; }
bool hasValidScanResults() const { return _scan.resultsValid; }
const WiFiScanRuntimeState& getScanRuntimeState() const { return _scan; }
const std::vector<WiFiScanNetwork>& getScanResults() const { return _scanResultsCache; }
// get the AP name of the config portal, so it can be used in the callback
String getConfigPortalSSID();
int getRSSIasQuality(int RSSI);
@@ -460,7 +511,7 @@ class WiFiManager
}
}
// Legacy constructor for backward compatibility (if needed during transition)
// Default constructor for tests and manually assembled argument sets
WiFiManagerRequestArgs() {}
// Check if argument exists
@@ -529,13 +580,12 @@ class WiFiManager
uint8_t _lastconxresult = WL_IDLE_STATUS; // store last result when doing connect operations
int _numNetworks = 0; // init index for numnetworks wifiscans
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
// async scan state management
bool _scanInProgress = false; // flag indicating scan is currently running
bool _scanRequested = false; // flag indicating scan was requested but not yet started
unsigned long _scanRequestTime = 0; // ms when scan was requested
WiFiScanRuntimeState _scan;
std::vector<WiFiScanNetwork> _scanResultsCache;
bool _scanLifecycleBlocked = false;
// async reboot/abort scheduling
bool _rebootScheduled = false; // flag for scheduled reboot
@@ -697,6 +747,19 @@ protected:
bool WiFi_scanNetworks(bool force); // Always async - returns false if scan started but not complete
bool WiFi_scanNetworks(unsigned int cachetime);
void WiFi_scanComplete(int networksFound);
void processScan();
bool shouldScheduleScan(unsigned int cachetime,
wm_scan_schedule_reason_t reason,
bool forceRefresh = false) const;
void scheduleScan(wm_scan_schedule_reason_t reason, bool forceRefresh = false);
bool startAsyncScan();
void finalizeAsyncScan(int networksFound);
void failAsyncScan(wm_scan_state_t state, int scanResult = WIFI_SCAN_FAILED);
void resetAsyncScan(bool clearResults);
void invalidateScanResults();
bool hasFreshScanResults(unsigned int cachetime) const;
bool canRunAsyncScan() const;
void cacheScanResults(int networksFound);
bool WiFiSetCountry();
#ifdef ESP32
@@ -734,6 +797,37 @@ protected:
#endif
#endif
#ifdef UNIT_TEST
public:
void wmTestForceScanState(wm_scan_state_t state) { _scan.state = state; }
void wmTestSetScanStartedAt(unsigned long startedAt) { _scan.startedAt = startedAt; }
void wmTestSetScanTimeoutMs(unsigned long timeoutMs) { _scan.timeoutMs = timeoutMs; }
void wmTestInjectScanResults(const std::vector<WiFiScanNetwork>& results) {
_scanResultsCache = results;
_numNetworks = static_cast<int>(results.size());
_scan.visibleNetworkCount = _numNetworks;
_scan.resultsValid = true;
_scan.state = WM_SCAN_COMPLETE;
_lastscan = millis();
_scan.finishedAt = _lastscan;
}
void wmTestSetScanCompletionPending(int completionResult) {
_scan.completionPending = true;
_scan.completionResult = completionResult;
}
void wmTestSetPortalActive(bool active) { configPortalActive = active; }
void wmTestSetConnectPending(bool active) { connect = active; }
void wmTestSetScanLifecycleBlocked(bool blocked) { _scanLifecycleBlocked = blocked; }
void wmTestSetScanGenerations(uint32_t generation, uint32_t runningGeneration, uint32_t completionGeneration) {
_scan.generation = generation;
_scan.runningGeneration = runningGeneration;
_scan.completionGeneration = completionGeneration;
}
void wmTestClearScanResults() { resetAsyncScan(true); }
#endif
protected:
//helpers (rendering methods moved to WiFiManagerHandlers)
boolean isIp(String str);
String toStringIp(IPAddress ip);
@@ -64,7 +64,7 @@ class WiFiManagerHandlers {
void handleErase(AsyncWebServerRequest *request, boolean opt);
void handleNotFound(AsyncWebServerRequest *request);
void handleRequest(AsyncWebServerRequest *request);
void handleWiFiStatus(AsyncWebServerRequest *request);
void handleWiFiScanRequest(AsyncWebServerRequest *request);
void handleWiFiScanStatus(AsyncWebServerRequest *request);
void handleUpdate(AsyncWebServerRequest *request);
void handleUpdating(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final);
@@ -75,8 +75,6 @@ class WiFiManagerHandlers {
void stopCaptivePortal();
// Rendering Methods
String getHTTPHead(String title, String classes = "");
String getHTTPEnd();
String getMenuOut();
String getMenuOut(String* outOpt);
String getScanItemOut();
@@ -91,6 +89,8 @@ class WiFiManagerHandlers {
private:
WiFiManager* _wm;
void collectVisibleScanResults(std::vector<const WiFiManager::WiFiScanNetwork*>& networks);
void appendVisibleScanResultsJson(String& json, const std::vector<const WiFiManager::WiFiScanNetwork*>& networks);
};
#endif // defined(ESP8266) || defined(ESP32)
+1 -1
View File
@@ -46,8 +46,8 @@ const char R_restart[] PROGMEM = "/restart";
const char R_exit[] PROGMEM = "/exit";
const char R_close[] PROGMEM = "/close";
const char R_erase[] PROGMEM = "/erase";
const char R_status[] PROGMEM = "/status";
const char R_scanstatus[] PROGMEM = "/wifistatus";
const char R_scan[] PROGMEM = "/wifi/scan";
const char R_update[] PROGMEM = "/update";
const char R_updatedone[] PROGMEM = "/u";
@@ -0,0 +1,53 @@
/**
* Fragments.h
* Reusable DFTE fragment templates for WiFiManager page composition.
*/
#ifndef _WM_FRAGMENTS_TEMPLATE_H_
#define _WM_FRAGMENTS_TEMPLATE_H_
#include <Arduino.h>
const char WM_ACTION_FORM_TEMPLATE[] PROGMEM =
"%ACTION_PREFIX%<form action='%ACTION%' method='%METHOD%'><button%BUTTON_CLASS_ATTR%>%BUTTON_LABEL%</button></form>%ACTION_SUFFIX%";
const char WM_SUBMIT_BUTTON_TEMPLATE[] PROGMEM =
"<br/><br/><button type='submit'>%BUTTON_LABEL%</button>";
const char WM_CENTERED_BUTTON_TEMPLATE[] PROGMEM =
"<br/><div class='c'><button id='%BUTTON_ID%' type='%BUTTON_TYPE%' onclick='%BUTTON_ONCLICK%'%BUTTON_EXTRA_ATTRS%>%BUTTON_LABEL%</button></div>";
const char WM_SECTION_BREAK_TEMPLATE[] PROGMEM =
"<hr><br/>%SECTION_CONTENT%";
const char WM_SCAN_MESSAGE_TEMPLATE[] PROGMEM =
"%SCAN_MESSAGE%<br/><br/>";
const char WM_SCAN_RESULT_ROW_TEMPLATE[] PROGMEM =
"<div><a href='#p' onclick='c(this)' data-ssid='%SSID_ATTR%'>%SSID_TEXT%</a>"
"<div role='img' aria-label='%QUALITY_LABEL%' title='%QUALITY_LABEL%' class='q q-%QUALITY_ICON% %LOCK_CLASS% %ICON_VISIBILITY_CLASS%'></div>"
"<div class='q %VALUE_VISIBILITY_CLASS%'>%QUALITY_VALUE%</div></div>";
const char WM_FIELD_LABEL_BEFORE_TEMPLATE[] PROGMEM =
"<label for='%FIELD_ID%'>%FIELD_LABEL%</label><br/><input id='%FIELD_ID%' name='%FIELD_NAME%' maxlength='%FIELD_MAXLENGTH%' value='%FIELD_VALUE%'%FIELD_EXTRA_ATTRS%>";
const char WM_FIELD_LABEL_AFTER_TEMPLATE[] PROGMEM =
"<br/><input id='%FIELD_ID%' name='%FIELD_NAME%' maxlength='%FIELD_MAXLENGTH%' value='%FIELD_VALUE%'%FIELD_EXTRA_ATTRS%>"
"<label for='%FIELD_ID%'>%FIELD_LABEL%</label>";
const char WM_FIELD_INPUT_ONLY_TEMPLATE[] PROGMEM =
"<br/><input id='%FIELD_ID%' name='%FIELD_NAME%' maxlength='%FIELD_MAXLENGTH%' value='%FIELD_VALUE%'%FIELD_EXTRA_ATTRS%>";
const char WM_INFO_ROW_TEMPLATE[] PROGMEM =
"<dt>%INFO_LABEL%</dt><dd>%INFO_VALUE%</dd>";
const char WM_INFO_SECTION_TEMPLATE[] PROGMEM =
"%SECTION_PREFIX%<h3>%SECTION_TITLE%</h3><hr><dl>%SECTION_ROWS%</dl>%SECTION_SUFFIX%";
const char WM_STATUS_MESSAGE_TEMPLATE[] PROGMEM =
"<div class='msg%STATUS_CLASS_SUFFIX%'><strong>%STATUS_TITLE%</strong>%STATUS_BODY%</div>";
const char WM_PAGE_HEADING_TEMPLATE[] PROGMEM =
"<h1>%HEADER_TITLE%</h1><h3>%HEADER_SUBTITLE%</h3>";
#endif // _WM_FRAGMENTS_TEMPLATE_H_
+5 -72
View File
@@ -9,39 +9,6 @@
#include <Arduino.h>
// HTML document structure
const char HTML_HEAD_START[] PROGMEM =
"<!DOCTYPE html>"
"<html lang='en'><head>"
"<meta name='format-detection' content='telephone=no'>"
"<meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1,user-scalable=no'/>"
"<title>";
const char HTML_TITLE_END[] PROGMEM = "</title>";
const char HTML_HEAD_END_START[] PROGMEM = "</head><body class='";
const char HTML_HEAD_END_WRAP[] PROGMEM = "'><div class='wrap'>";
const char HTML_END[] PROGMEM = "</div></body></html>";
// Form elements
const char HTML_FORM_END[] PROGMEM = "<br/><br/><button type='submit'>Save</button></form>";
const char HTML_FORM_STATIC_HEAD[] PROGMEM = "<hr><br/>";
const char HTML_FORM_PARAM_HEAD[] PROGMEM = "<hr><br/>";
const char HTML_FORM_WIFI_END[] PROGMEM = "";
const char HTML_BR[] PROGMEM = "<br/>";
// Buttons
const char HTML_BACKBTN[] PROGMEM = "<hr><br/><form action='/' method='get'><button>Back</button></form>";
const char HTML_ERASEBTN[] PROGMEM = "<br/><form action='/erase' method='get'><button class='D'>Erase WiFi config</button></form>";
// Status messages
const char HTML_STATUS_OFFPW[] PROGMEM = "<br/>Authentication failure";
@@ -62,22 +29,6 @@ const char HTML_UPDATE_FAIL[] PROGMEM = "<div class='msg D'><strong>Update faile
const char HTML_UPDATE_SUCCESS[] PROGMEM = "<div class='msg S'><strong>Update successful. </strong> <br/> Device rebooting now...</div>";
// Portal menu
const char * const HTML_PORTAL_MENU[] PROGMEM = {
"<form action='/wifi' method='get'><button>Configure WiFi</button></form><br/>\n", // MENU_WIFI
"<form action='/0wifi' method='get'><button>Configure WiFi (No scan)</button></form><br/>\n", // MENU_WIFINOSCAN
"<form action='/info' method='get'><button>Info</button></form><br/>\n", // MENU_INFO
"<form action='/param' method='get'><button>Setup</button></form><br/>\n",//MENU_PARAM
"<form action='/close' method='get'><button>Close</button></form><br/>\n", // MENU_CLOSE
"<form action='/restart' method='get'><button>Restart</button></form><br/>\n",// MENU_RESTART
"<form action='/exit' method='get'><button>Exit</button></form><br/>\n", // MENU_EXIT
"<form action='/erase' method='get'><button class='D'>Erase</button></form><br/>\n", // MENU_ERASE
"<form action='/update' method='get'><button>Update</button></form><br/>\n",// MENU_UPDATE
"<hr><br/>" // MENU_SEP
};
const char HTML_PORTAL_OPTIONS[] PROGMEM = "";
// Help page
#ifndef WM_NOHELP
const char HTML_HELP[] PROGMEM =
@@ -87,15 +38,17 @@ const char HTML_HELP[] PROGMEM =
"<tr><td><a href='/'>/</a></td>"
"<td>Menu page.</td></tr>"
"<tr><td><a href='/wifi'>/wifi</a></td>"
"<td>Show WiFi scan results and enter WiFi configuration.(/0wifi noscan)</td></tr>"
"<td>Show WiFi scan results and enter WiFi configuration.</td></tr>"
"<tr><td><a href='/0wifi'>/0wifi</a></td>"
"<td>Show WiFi configuration without loading scan results until refresh is requested.</td></tr>"
"<tr><td><a href='/wifisave'>/wifisave</a></td>"
"<td>Save WiFi configuration information and configure device. Needs variables supplied.</td></tr>"
"<tr><td><a href='/param'>/param</a></td>"
"<td>Parameter page</td></tr>"
"<tr><td><a href='/info'>/info</a></td>"
"<td>Information page</td></tr>"
"<tr><td><a href='/u'>/u</a></td>"
"<td>OTA Update</td></tr>"
"<tr><td><a href='/update'>/update</a></td>"
"<td>OTA update page. Firmware upload posts to /u.</td></tr>"
"<tr><td><a href='/close'>/close</a></td>"
"<td>Close the captiveportal popup, config portal will remain active</td></tr>"
"<tr><td>/exit</td>"
@@ -110,25 +63,5 @@ const char HTML_HELP[] PROGMEM =
const char HTML_HELP[] PROGMEM = "";
#endif
#ifdef WM_JSTEST
const char HTML_JS[] PROGMEM =
"<script>function postAjax(url, data, success) {"
" var params = typeof data == 'string' ? data : Object.keys(data).map("
" function(k){ return encodeURIComponent(k) + '=' + encodeURIComponent(data[k]) }"
" ).join('&');"
" var xhr = window.XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject(\"Microsoft.XMLHTTP\");"
" xhr.open('POST', url);"
" xhr.onreadystatechange = function() {"
" if (xhr.readyState>3 && xhr.status==200) { success(xhr.responseText); }"
" };"
" xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');"
" xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');"
" xhr.send(params);"
" return xhr;}"
"postAjax('/status', 'p1=1&p2=Hello+World', function(data){ console.log(data); });"
"postAjax('/status', { p1: 1, p2: 'Hello World' }, function(data){ console.log(data); });"
"</script>";
#endif
#endif // _HTML_TEMPLATES_H_
+6 -20
View File
@@ -1,6 +1,6 @@
/**
* Info.h
* Streamed info page template for WiFiManager.
* Streamed info page content template for WiFiManager.
*/
#ifndef _WM_INFO_TEMPLATE_H_
@@ -8,25 +8,11 @@
#include <Arduino.h>
const char WM_INFO_TEMPLATE[] PROGMEM =
"<!DOCTYPE html>"
"<html lang='en'>"
"<head>"
"<meta name='format-detection' content='telephone=no'>"
"<meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1,user-scalable=no'/>"
"<title>%DOC_TITLE%</title>"
"%SCRIPTS%"
"%STYLES%"
"</head>"
"<body class='info'>"
"<div class='wrap'>"
const char WM_INFO_CONTENT_TEMPLATE[] PROGMEM =
"%INFO_STATUS%"
"%INFO_DEVICE_SECTION%"
"%INFO_WIFI_SECTION%"
"%INFO_ABOUT_SECTION%"
"</div>"
"</body>"
"</html>";
"<section class='info-device'>%INFO_DEVICE_SECTION%</section>"
"<section class='info-wifi'>%INFO_WIFI_SECTION%</section>"
"<section class='info-about'>%INFO_ABOUT_SECTION%</section>"
"%INFO_FOOTER%";
#endif // _WM_INFO_TEMPLATE_H_
+34 -10
View File
@@ -9,16 +9,40 @@
#include <Arduino.h>
const char JS_SCRIPT[] PROGMEM = "<script>function c(l){"
"document.getElementById('s').value=l.getAttribute('data-ssid')||l.innerText||l.textContent;"
"var parent = l.parentElement;"
"var isEncrypted = parent.querySelector('.l') !== null || (l.nextElementSibling && l.nextElementSibling.classList.contains('l'));"
"var pField = document.getElementById('p');"
"if(isEncrypted){pField.removeAttribute('disabled');pField.focus();}else{pField.setAttribute('disabled','');};"
"};"
"function f() {var x = document.getElementById('p');x.type==='password'?x.type='text':x.type='password';}"
"window.addEventListener('load',function(){var pField = document.getElementById('p');pField.removeAttribute('disabled');});"
"</script>"; // @todo add button states, disable on click , show ack , spinner etc
const char JS_SCRIPT[] PROGMEM = R"rawliteral(
<script>
function c(l){
var ssidField = document.getElementById('s');
if (!ssidField) return;
ssidField.value = l.getAttribute('data-ssid') || l.innerText || l.textContent;
var parent = l.parentElement;
var isEncrypted = parent.querySelector('.l') !== null || (l.nextElementSibling && l.nextElementSibling.classList.contains('l'));
var pField = document.getElementById('p');
if (!pField) return;
if (isEncrypted) {
pField.removeAttribute('disabled');
pField.focus();
} else {
pField.setAttribute('disabled', '');
}
}
function f() {
var x = document.getElementById('p');
if (!x) return;
x.type = x.type === 'password' ? 'text' : 'password';
}
window.addEventListener('load', function() {
var pField = document.getElementById('p');
if (pField) {
pField.removeAttribute('disabled');
}
});
</script>
)rawliteral";
#endif // _JS_TEMPLATES_H_
@@ -0,0 +1,15 @@
/**
* Message.h
* Streamed message page content template for WiFiManager.
*/
#ifndef _WM_MESSAGE_TEMPLATE_H_
#define _WM_MESSAGE_TEMPLATE_H_
#include <Arduino.h>
const char WM_MESSAGE_CONTENT_TEMPLATE[] PROGMEM =
"%MESSAGE_BODY%"
"%MESSAGE_ACTIONS%";
#endif // _WM_MESSAGE_TEMPLATE_H_
@@ -17,7 +17,9 @@ const char WM_PAGE_SHELL_TEMPLATE[] PROGMEM =
"<meta name='viewport' content='width=device-width,initial-scale=1,user-scalable=no'/>"
"<title>%DOC_TITLE%</title>"
"%SCRIPTS%"
"%PAGE_SCRIPTS%"
"%STYLES%"
"%PAGE_STYLES%"
"</head>"
"<body class='%BODY_CLASS%'>"
"<div class='wrap'>"
+18
View File
@@ -0,0 +1,18 @@
/**
* Param.h
* Streamed setup page content template for WiFiManager.
*/
#ifndef _WM_PARAM_TEMPLATE_H_
#define _WM_PARAM_TEMPLATE_H_
#include <Arduino.h>
const char WM_PARAM_CONTENT_TEMPLATE[] PROGMEM =
"<form method='POST' action='paramsave'>"
"%PARAM_FIELDS%"
"%PARAM_FORM_ACTIONS%</form>"
"%PARAM_PAGE_ACTIONS%"
"%PARAM_STATUS%";
#endif // _WM_PARAM_TEMPLATE_H_
+15 -21
View File
@@ -1,6 +1,6 @@
/**
* WiFi.h
* Streamed WiFi configuration page template for WiFiManager.
* Streamed WiFi configuration page content template for WiFiManager.
*/
#ifndef _WM_WIFI_TEMPLATE_H_
@@ -8,25 +8,19 @@
#include <Arduino.h>
const char WM_WIFI_TEMPLATE[] PROGMEM =
"<!DOCTYPE html>"
"<html lang='en'>"
"<head>"
"<meta name='format-detection' content='telephone=no'>"
"<meta charset='UTF-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1,user-scalable=no'/>"
"<title>%DOC_TITLE%</title>"
"%SCRIPTS%"
"%STYLES%"
"</head>"
"<body class='wifi'>"
"<div class='wrap'>"
"%WIFI_SCAN_RESULTS%"
"%WIFI_FORM_SECTION%"
"%WIFI_BACK_SECTION%"
"%WIFI_STATUS%"
"</div>"
"</body>"
"</html>";
const char WM_WIFI_CONTENT_TEMPLATE[] PROGMEM =
"<div id='scan-results'>%WIFI_SCAN_CONTENT%</div>"
"<form method='POST' action='wifisave'>"
"<label for='s'>SSID</label>"
"<input id='s' name='s' maxlength='32' autocorrect='off' autocapitalize='none' placeholder='%WIFI_SSID_PLACEHOLDER%'>"
"<br/>"
"<label for='p'>Password</label>"
"<input id='p' name='p' maxlength='64' type='password' placeholder='%WIFI_PASSWORD_PLACEHOLDER%'>"
"<input type='checkbox' id='showpass' onclick='f()'> <label for='showpass'>Show Password</label><br/>"
"%WIFI_STATIC_FIELDS%"
"%WIFI_PARAM_SECTION%"
"%WIFI_FORM_ACTIONS%</form>"
"%WIFI_PAGE_ACTIONS%"
"%WIFI_STATUS%";
#endif // _WM_WIFI_TEMPLATE_H_
@@ -12,49 +12,81 @@
const char PROGMEM WIFI_POLLING_JS[] = R"rawliteral(
<script>
let scanPollInterval = null;
let isPolling = false;
let scanBusy = false;
function refreshScan() {
// Trigger scan via refresh parameter
fetch('/wifi?refresh=1', {method: 'GET'}).then(() => {
function setRefreshButtonState(disabled, label) {
const btn = document.getElementById('refresh-btn');
if (!btn) return;
btn.disabled = disabled;
btn.textContent = label;
}
function showScanMessage(message) {
const scanResults = document.getElementById('scan-results');
if (!scanResults) return;
scanResults.innerHTML = message + '<br/><br/>';
}
async function refreshScan() {
if (scanBusy) return false;
scanBusy = true;
setRefreshButtonState(true, 'Scanning...');
try {
const response = await fetch('/wifi/scan', { method: 'POST' });
if (!response.ok) throw new Error('scan request failed');
startPolling();
});
} catch (error) {
scanBusy = false;
setRefreshButtonState(false, 'Refresh');
showScanMessage('Could not start scan. Try again.');
}
return false;
}
function startPolling() {
if(isPolling) return;
isPolling = true;
document.getElementById('refresh-btn').disabled = true;
document.getElementById('refresh-btn').textContent = 'Scanning...';
if (scanPollInterval) return;
updateScanStatus();
scanPollInterval = setInterval(updateScanStatus, 1000);
}
function stopPolling() {
if(scanPollInterval) {
if (scanPollInterval) {
clearInterval(scanPollInterval);
scanPollInterval = null;
}
isPolling = false;
document.getElementById('refresh-btn').disabled = false;
document.getElementById('refresh-btn').textContent = 'Refresh';
scanBusy = false;
setRefreshButtonState(false, 'Refresh');
}
function updateScanStatus() {
fetch('/wifistatus')
.then(response => response.json())
.then(data => {
if(data.scanning) {
// Still scanning, show status
document.getElementById('scan-results').innerHTML = 'Scanning for networks...<br/><br/>';
} else {
// Scan complete, update network list
stopPolling();
updateNetworkList(data);
if (data.scanning) {
showScanMessage('Scanning for networks...');
return;
}
if (data.error === 'timeout') {
showScanMessage('Scan timed out. Refresh to try again.');
stopPolling();
return;
}
if (data.error === 'failed') {
showScanMessage('Scan failed. Refresh to try again.');
stopPolling();
return;
}
stopPolling();
updateNetworkList(data);
})
.catch(error => {
console.error('Error polling scan status:', error);
showScanMessage('Scan status unavailable.');
stopPolling();
});
}
@@ -66,14 +98,11 @@ function updateNetworkList(data) {
} else if(data.networks && data.networks.length > 0) {
data.networks.forEach(function(network) {
let qualityPercent = network.quality + '%';
// Map quality 0-100 to icon level 1-4
let qualityIcon = Math.round((network.quality / 100) * 3) + 1;
if(qualityIcon < 1) qualityIcon = 1;
if(qualityIcon > 4) qualityIcon = 4;
// Encryption class: 'l' if encrypted, empty if open
let encClass = network.enc_type !== 0 ? 'l' : '';
let encClass = network.encrypted ? 'l' : '';
let ssidEscaped = escapeHtml(network.ssid);
// Match original template structure: icon div with quality + encryption class, then percentage div
html += '<div><a href="#p" onclick="c(this)" data-ssid="' + ssidEscaped + '">' + ssidEscaped + '</a>';
html += '<div role="img" aria-label="' + qualityPercent + '" title="' + qualityPercent + '" class="q q-' + qualityIcon + ' ' + encClass + '"></div>';
html += '<div class="q">' + qualityPercent + '</div>';
@@ -93,21 +122,43 @@ function escapeHtml(text) {
return text.replace(/[&<>"']/g, function(m) { return map[m]; });
}
function getQualityIcon(quality) {
if(quality >= 75) return '▂▄▆█';
if(quality >= 50) return '▂▄▆▁';
if(quality >= 25) return '▂▄▁▁';
return '▂▁▁▁';
}
// Check if scan is in progress on page load
window.addEventListener('load', function() {
const refreshButton = document.getElementById('refresh-btn');
const skipInitialScan = refreshButton && refreshButton.dataset.skipInitialScan === 'true';
const pField = document.getElementById('p');
if (pField) {
pField.removeAttribute('disabled');
}
if (skipInitialScan) {
return;
}
fetch('/wifistatus')
.then(response => response.json())
.then(data => {
if(data.scanning) {
if (data.scanning) {
scanBusy = true;
setRefreshButtonState(true, 'Scanning...');
startPolling();
return;
}
if (data.error === 'timeout') {
showScanMessage('Scan timed out. Refresh to try again.');
return;
}
if (data.error === 'failed') {
showScanMessage('Scan failed. Refresh to try again.');
return;
}
updateNetworkList(data);
})
.catch(error => {
console.error('Error loading initial scan status:', error);
showScanMessage('Scan status unavailable.');
});
});
</script>
+401 -123
View File
@@ -19,6 +19,39 @@
uint8_t WiFiManager::_lastconxresulttmp = WL_IDLE_STATUS;
#endif
namespace {
constexpr size_t kMaxHostnameLength = 32;
constexpr uint8_t kSoftApStartMaxAttempts = 3;
bool isValidHostnameChar(char c) {
return isAlphaNumeric(c) || c == '-';
}
bool normalizeHostname(String& hostname) {
hostname.trim();
if (hostname.length() > kMaxHostnameLength) {
return false;
}
if (hostname.length() == 0) {
return true;
}
if (hostname[0] == '-' || hostname[hostname.length() - 1] == '-') {
return false;
}
for (size_t i = 0; i < hostname.length(); i++) {
if (!isValidHostnameChar(hostname[i])) {
return false;
}
}
return true;
}
} // namespace
/**
* Add a custom parameter to the config portal
* @param p Pointer to WiFiManagerParameter to add
@@ -354,24 +387,27 @@ bool WiFiManager::startAP(){
#endif
}
// Start soft AP with password or anonymous
if (_apPassword != "") {
if(channel>0){
ret = WiFi.softAP(_apName.c_str(), _apPassword.c_str(),channel,_apHidden);
}
else{
ret = WiFi.softAP(_apName.c_str(), _apPassword.c_str(),1,_apHidden);//password option
uint8_t apChannel = channel > 0 ? channel : 1;
auto startSoftAP = [&]() -> bool {
if (_apPassword != "") {
return WiFi.softAP(_apName.c_str(), _apPassword.c_str(), apChannel, _apHidden);
}
} else {
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_VERBOSE,F("AP has anonymous access!"));
DEBUG_WM(WM_DEBUG_VERBOSE,F("AP has anonymous access!"));
#endif
if(channel>0){
ret = WiFi.softAP(_apName.c_str(),"",channel,_apHidden);
}
else{
ret = WiFi.softAP(_apName.c_str(),"",1,_apHidden);
}
return WiFi.softAP(_apName.c_str(), "", apChannel, _apHidden);
};
// Start soft AP with password or anonymous
ret = startSoftAP();
for (uint8_t attempt = 1; !ret && attempt < kSoftApStartMaxAttempts; attempt++) {
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_ERROR, F("[ERROR] softAP start failed, retry attempt"), attempt + 1);
#endif
WiFi.softAPdisconnect(false);
delay(150 * attempt);
ret = startSoftAP();
}
if(_debugLevel >= WM_DEBUG_DEV) debugSoftAPConfig();
@@ -379,7 +415,6 @@ bool WiFiManager::startAP(){
delay(500); // slight delay to make sure we get an AP IP
#ifdef WM_DEBUG_LEVEL
if(!ret) DEBUG_WM(WM_DEBUG_ERROR,F("[ERROR] There was a problem starting the AP"));
// TODO: Implement simple retry/backoff if AP start fails (bounded attempts)
DEBUG_WM(F("AP IP address:"),WiFi.softAPIP());
#endif
@@ -467,8 +502,8 @@ void WiFiManager::setupConfigPortal() {
}
_serverManager->createServer(_httpPort);
_serverManager->registerRoutes();
_lastscan = 0; // reset network scan cache
if(_preloadwifiscan) WiFi_scanNetworks(true); // preload wifiscan (async)
resetAsyncScan(true);
if(_preloadwifiscan) scheduleScan(WM_SCAN_SCHEDULE_PRELOAD); // process() starts it later
}
void WiFiManager::startConfigPortal() {
@@ -518,6 +553,7 @@ void WiFiManager::startConfigPortal(char const *apName, char const *apPassword)
// init configportal globals to known states
configPortalActive = true;
connect = abort = false; // loop flags, connect true success, abort true break
_scanLifecycleBlocked = true;
_configPortalStart = millis();
@@ -548,7 +584,7 @@ void WiFiManager::startConfigPortal(char const *apName, char const *apPassword)
if (_serverManager) {
_serverManager->setupDNSD();
}
_scanLifecycleBlocked = false;
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_VERBOSE,F("Config Portal Running (call process() periodically)"));
@@ -579,30 +615,7 @@ boolean WiFiManager::process(){
MDNS.update();
#endif
// Poll WiFi scan status (non-blocking)
// Check if scan is in progress and poll for completion
if(_scanInProgress){
int8_t scanStatus = WiFi.scanComplete();
if(scanStatus >= 0){
// Scan completed
WiFi_scanComplete(scanStatus);
}
else if(scanStatus == WIFI_SCAN_FAILED){
// Scan failed
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_ERROR,F("[ERROR] scan failed during polling"));
#endif
_scanInProgress = false;
_scanRequested = false;
}
// If WIFI_SCAN_RUNNING, continue polling next time (non-blocking)
}
// Handle queued scan requests
if(_scanRequested && !_scanInProgress){
WiFi_scanNetworks(true);
_scanRequested = false;
}
processScan();
if(webPortalActive || configPortalActive){
// if timed out or abort, break
@@ -639,6 +652,8 @@ uint8_t WiFiManager::processConfigPortal(){
// Waiting for save...
if(connect) {
connect = false;
_scanLifecycleBlocked = true;
resetAsyncScan(false);
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_VERBOSE,F("processing save"));
#endif
@@ -670,8 +685,12 @@ uint8_t WiFiManager::processConfigPortal(){
#endif
_savewificallback(); // @CALLBACK
}
if(!_connectonsave) return WL_IDLE_STATUS;
if(!_connectonsave) {
_scanLifecycleBlocked = false;
return WL_IDLE_STATUS;
}
if(_disableConfigPortal) shutdownConfigPortal();
_scanLifecycleBlocked = false;
return WL_CONNECTED; // CONNECT SUCCESS
}
#ifdef WM_DEBUG_LEVEL
@@ -688,6 +707,7 @@ uint8_t WiFiManager::processConfigPortal(){
_savewificallback(); // @CALLBACK
}
if(_disableConfigPortal) shutdownConfigPortal();
_scanLifecycleBlocked = false;
return WL_CONNECT_FAILED; // CONNECT FAIL
}
else{
@@ -696,6 +716,7 @@ uint8_t WiFiManager::processConfigPortal(){
DEBUG_WM(WM_DEBUG_VERBOSE,F("Portal remaining open"));
#endif
}
_scanLifecycleBlocked = false;
}
return WL_IDLE_STATUS;
@@ -723,10 +744,12 @@ bool WiFiManager::shutdownConfigPortal(){
_serverManager.reset();
}
WiFi.scanDelete(); // free wifi scan results
resetAsyncScan(true);
if(!configPortalActive) return false;
_scanLifecycleBlocked = true;
// Turn off AP
bool ret = false;
ret = WiFi.softAPdisconnect(false);
@@ -750,6 +773,7 @@ bool WiFiManager::shutdownConfigPortal(){
configPortalActive = false;
DEBUG_WM(WM_DEBUG_VERBOSE,F("configportal closed"));
_end();
_scanLifecycleBlocked = false;
return ret;
}
@@ -994,14 +1018,12 @@ void WiFiManager::startWPS() {
#endif
void WiFiManager::WiFi_scanComplete(int networksFound){
_lastscan = millis();
_numNetworks = networksFound;
_scanInProgress = false;
_scanRequested = false;
#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);
#endif
if (_scan.state != WM_SCAN_RUNNING) {
return;
}
_scan.completionPending = true;
_scan.completionResult = networksFound;
_scan.completionGeneration = _scan.runningGeneration;
}
bool WiFiManager::WiFi_scanNetworks(){
@@ -1009,85 +1031,321 @@ bool WiFiManager::WiFi_scanNetworks(){
}
bool WiFiManager::WiFi_scanNetworks(unsigned int cachetime){
return WiFi_scanNetworks(millis()-_lastscan > cachetime);
if (hasFreshScanResults(cachetime)) {
return true;
}
scheduleScan(WM_SCAN_SCHEDULE_STALE_CACHE);
return false;
}
bool WiFiManager::WiFi_scanNetworks(bool force){
// If 0 networks found, force rescan if autoforcerescan is enabled
if(_numNetworks == 0 && _autoforcerescan){
DEBUG_WM(WM_DEBUG_DEV,"NO APs found forcing new scan");
force = true;
}
// if scan is empty or stale (last scantime > _scancachetime), this avoids fast reloading wifi page and constant scan delayed page loads appearing to freeze.
if(!_lastscan || (_lastscan>0 && (millis()-_lastscan > _scancachetime))){
force = true;
}
// If scan already in progress, don't start another one
if(_scanInProgress){
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_VERBOSE,F("WiFi Scan already in progress"));
#endif
return false; // Scan in progress, not complete yet
}
if(force){
_startscan = millis();
_scanRequestTime = millis();
#ifdef ESP8266
#ifndef WM_NOASYNC // no async available < 2.4.0
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_VERBOSE,F("WiFi Scan ASYNC started"));
#endif
using namespace std::placeholders; // for `_1`
WiFi.scanNetworksAsync(std::bind(&WiFiManager::WiFi_scanComplete,this,_1));
_scanInProgress = true;
return false; // Scan started, not complete yet
#else
// ESP8266 < 2.4.0 - no async available, but we can't block here
// Return false and let process() handle it
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_ERROR,F("[ERROR] Async scan not available on this ESP8266 core"));
#endif
return false;
#endif
#else // ESP32
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_VERBOSE,F("WiFi Scan ASYNC started"));
#endif
int8_t res = WiFi.scanNetworks(true);
if(res == WIFI_SCAN_RUNNING){
_scanInProgress = true;
return false; // Scan started, not complete yet
}
else if(res == WIFI_SCAN_FAILED){
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_ERROR,F("[ERROR] scan failed"));
#endif
_scanInProgress = false;
return false;
}
else if(res >= 0){
// Scan completed immediately (unlikely but possible)
_numNetworks = res;
_lastscan = millis();
_scanInProgress = false;
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_VERBOSE,F("WiFi Scan completed immediately"), "in "+(String)(_lastscan - _startscan)+" ms");
#endif
return true;
}
return false;
#endif
}
else {
if(!force && hasFreshScanResults(_scancachetime)){
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_VERBOSE,F("Scan is cached"),(String)(millis()-_lastscan )+" ms ago");
#endif
return true; // Using cached data
return true;
}
if (force) {
scheduleScan(WM_SCAN_SCHEDULE_USER_REFRESH, true);
} else {
scheduleScan(WM_SCAN_SCHEDULE_STALE_CACHE);
}
return false;
}
void WiFiManager::requestAsyncScan(bool forceRefresh) {
scheduleScan(WM_SCAN_SCHEDULE_USER_REFRESH, forceRefresh);
}
bool WiFiManager::shouldScheduleScan(unsigned int cachetime,
wm_scan_schedule_reason_t reason,
bool forceRefresh) const {
if (forceRefresh) {
return true;
}
switch (reason) {
case WM_SCAN_SCHEDULE_USER_REFRESH:
return true;
case WM_SCAN_SCHEDULE_PRELOAD:
case WM_SCAN_SCHEDULE_STALE_CACHE:
case WM_SCAN_SCHEDULE_UI_RESUME:
return !hasFreshScanResults(cachetime);
case WM_SCAN_SCHEDULE_NONE:
default:
return false;
}
}
void WiFiManager::scheduleScan(wm_scan_schedule_reason_t reason, bool forceRefresh) {
if (_numNetworks == 0 && _autoforcerescan) {
forceRefresh = true;
}
if (!shouldScheduleScan(_scancachetime, reason, forceRefresh)) {
return;
}
_scan.schedulePending = true;
_scan.forceRefresh = _scan.forceRefresh || forceRefresh;
_scan.requestedAt = millis();
_scan.scheduledReason = reason;
if (_scan.state != WM_SCAN_RUNNING && _scan.state != WM_SCAN_QUEUED) {
_scan.state = WM_SCAN_QUEUED;
_scan.lastScanResult = WIFI_SCAN_RUNNING;
}
}
void WiFiManager::processScan() {
const unsigned long now = millis();
if (_scan.completionPending) {
const int completionResult = _scan.completionResult;
const uint32_t completionGeneration = _scan.completionGeneration;
_scan.completionPending = false;
if (completionGeneration == _scan.runningGeneration) {
if (completionResult >= 0) {
finalizeAsyncScan(completionResult);
} else {
failAsyncScan(completionResult == WIFI_SCAN_FAILED ? WM_SCAN_FAILED : WM_SCAN_TIMEOUT, completionResult);
}
} else {
_scan.completionGeneration = 0;
}
}
if ((_scan.state == WM_SCAN_RUNNING || _scan.state == WM_SCAN_QUEUED) && !canRunAsyncScan()) {
resetAsyncScan(false);
return;
}
switch (_scan.state) {
case WM_SCAN_IDLE:
case WM_SCAN_COMPLETE:
case WM_SCAN_FAILED:
case WM_SCAN_TIMEOUT:
if (_scan.schedulePending) {
_scan.state = WM_SCAN_QUEUED;
}
break;
case WM_SCAN_QUEUED:
startAsyncScan();
break;
case WM_SCAN_RUNNING:
if ((now - _scan.startedAt) > _scan.timeoutMs) {
failAsyncScan(WM_SCAN_TIMEOUT);
return;
}
#ifdef ESP32
if (!_scan.completionPending) {
int scanStatus = WiFi.scanComplete();
if (scanStatus >= 0) {
finalizeAsyncScan(scanStatus);
} else if (scanStatus == WIFI_SCAN_FAILED) {
failAsyncScan(WM_SCAN_FAILED, scanStatus);
}
}
#endif
break;
}
}
bool WiFiManager::startAsyncScan() {
const unsigned long now = millis();
if (!canRunAsyncScan()) {
return false;
}
if (_scan.finishedAt > 0 && (now - _scan.finishedAt) < _scan.minRestartIntervalMs) {
return false;
}
if (_scan.forceRefresh) {
invalidateScanResults();
}
_scan.completionPending = false;
_scan.completionResult = WIFI_SCAN_FAILED;
_scan.generation++;
_scan.runningGeneration = _scan.generation;
_scan.completionGeneration = 0;
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_VERBOSE,F("WiFi Scan ASYNC started"));
#endif
#ifdef ESP8266
#ifndef WM_NOASYNC
using namespace std::placeholders;
WiFi.scanDelete();
WiFi.scanNetworksAsync(std::bind(&WiFiManager::WiFi_scanComplete, this, _1));
_scan.state = WM_SCAN_RUNNING;
_scan.startedAt = now;
_scan.lastScanResult = WIFI_SCAN_RUNNING;
_scan.schedulePending = false;
_scan.forceRefresh = false;
return true;
#else
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_ERROR,F("[ERROR] Async scan not available on this ESP8266 core"));
#endif
failAsyncScan(WM_SCAN_FAILED);
return false;
#endif
#else
WiFi.scanDelete();
int scanResult = WiFi.scanNetworks(true);
if (scanResult == WIFI_SCAN_RUNNING) {
_scan.state = WM_SCAN_RUNNING;
_scan.startedAt = now;
_scan.lastScanResult = WIFI_SCAN_RUNNING;
_scan.schedulePending = false;
_scan.forceRefresh = false;
return true;
}
if (scanResult >= 0) {
finalizeAsyncScan(scanResult);
return true;
}
failAsyncScan(WM_SCAN_FAILED, scanResult);
return false;
#endif
}
void WiFiManager::cacheScanResults(int networksFound) {
_scanResultsCache.clear();
if (networksFound <= 0) {
return;
}
_scanResultsCache.reserve(static_cast<size_t>(networksFound));
for (int i = 0; i < networksFound; i++) {
WiFiScanNetwork network;
network.ssid = WiFi.SSID(i);
network.rssi = WiFi.RSSI(i);
network.encType = WiFi.encryptionType(i);
_scanResultsCache.push_back(network);
}
}
void WiFiManager::finalizeAsyncScan(int networksFound) {
if (_scan.completionGeneration != 0 && _scan.completionGeneration != _scan.runningGeneration) {
return;
}
_lastscan = millis();
_scan.finishedAt = _lastscan;
_scan.lastScanResult = networksFound;
_scan.state = WM_SCAN_COMPLETE;
_scan.resultsValid = networksFound >= 0;
_scan.schedulePending = false;
_scan.forceRefresh = false;
cacheScanResults(networksFound);
_numNetworks = static_cast<int>(_scanResultsCache.size());
_scan.visibleNetworkCount = _numNetworks;
_scan.completionGeneration = 0;
_scan.runningGeneration = 0;
WiFi.scanDelete();
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_VERBOSE,F("WiFi Scan ASYNC completed"), "in "+(String)(_scan.finishedAt - _scan.startedAt)+" ms");
DEBUG_WM(WM_DEBUG_VERBOSE,F("WiFi Scan ASYNC found:"),_numNetworks);
#endif
}
void WiFiManager::failAsyncScan(wm_scan_state_t state, int scanResult) {
WiFi.scanDelete();
_scan.finishedAt = millis();
_scan.lastScanResult = scanResult;
_scan.state = state;
_scan.resultsValid = false;
_scan.completionPending = false;
_scan.schedulePending = false;
_scan.forceRefresh = false;
_scan.visibleNetworkCount = 0;
_numNetworks = 0;
_lastscan = 0;
_scan.completionGeneration = 0;
_scan.runningGeneration = 0;
_scanResultsCache.clear();
#ifdef WM_DEBUG_LEVEL
if (state == WM_SCAN_TIMEOUT) {
DEBUG_WM(WM_DEBUG_ERROR,F("[ERROR] scan timed out"));
} else {
DEBUG_WM(WM_DEBUG_ERROR,F("[ERROR] scan failed"));
}
#endif
}
void WiFiManager::resetAsyncScan(bool clearResults) {
WiFi.scanDelete();
_scan.generation++;
_scan.state = WM_SCAN_IDLE;
_scan.resultsValid = clearResults ? false : _scan.resultsValid;
_scan.schedulePending = false;
_scan.forceRefresh = false;
_scan.completionPending = false;
_scan.requestedAt = 0;
_scan.startedAt = 0;
_scan.finishedAt = 0;
_scan.lastScanResult = WIFI_SCAN_FAILED;
_scan.completionResult = WIFI_SCAN_FAILED;
_scan.runningGeneration = 0;
_scan.completionGeneration = 0;
_scan.scheduledReason = WM_SCAN_SCHEDULE_NONE;
if (clearResults) {
_numNetworks = 0;
_lastscan = 0;
_scan.visibleNetworkCount = 0;
_scanResultsCache.clear();
} else {
_scan.visibleNetworkCount = _numNetworks;
}
}
void WiFiManager::invalidateScanResults() {
_scan.resultsValid = false;
_scan.visibleNetworkCount = 0;
_numNetworks = 0;
_lastscan = 0;
_scanResultsCache.clear();
}
bool WiFiManager::hasFreshScanResults(unsigned int cachetime) const {
if (!_scan.resultsValid || _scanResultsCache.empty() || _lastscan == 0) {
return false;
}
return cachetime == 0 || (millis() - _lastscan) <= cachetime;
}
bool WiFiManager::canRunAsyncScan() const {
if (!configPortalActive && !webPortalActive) {
return false;
}
if (_scanLifecycleBlocked || connect || _abortScheduled || _rebootScheduled || abort) {
return false;
}
return true;
}
// PUBLIC
@@ -1606,13 +1864,33 @@ void WiFiManager::setDisableConfigPortal(boolean enable)
* @return bool false if hostname is not valid
*/
bool WiFiManager::setHostname(const char * hostname){
// TODO: Enforce max 32-char hostname and reject/trim invalid input
_hostname = String(hostname);
if (hostname == nullptr) {
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_ERROR, F("[ERROR] hostname: null value rejected"));
#endif
return false;
}
String candidate(hostname);
if (!normalizeHostname(candidate)) {
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_ERROR, F("[ERROR] hostname: invalid value rejected"));
#endif
return false;
}
_hostname = candidate;
return true;
}
bool WiFiManager::setHostname(String hostname){
// TODO: Enforce max 32-char hostname and reject/trim invalid input
if (!normalizeHostname(hostname)) {
#ifdef WM_DEBUG_LEVEL
DEBUG_WM(WM_DEBUG_ERROR, F("[ERROR] hostname: invalid value rejected"));
#endif
return false;
}
_hostname = hostname;
return true;
}
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -222,13 +222,13 @@ void WiFiManagerServer::registerRoutes() {
this->_handlers->handleErase(request, false);
});
server->on(WM_G(R_status), HTTP_GET, [this](AsyncWebServerRequest *request) {
this->_handlers->handleWiFiStatus(request);
});
server->on(WM_G(R_scanstatus), HTTP_GET, [this](AsyncWebServerRequest *request) {
this->_handlers->handleWiFiScanStatus(request);
});
server->on(WM_G(R_scan), HTTP_POST, [this](AsyncWebServerRequest *request) {
this->_handlers->handleWiFiScanRequest(request);
});
// OTA Update routes
server->on(WM_G(R_update), HTTP_GET, [this](AsyncWebServerRequest *request) {
+14
View File
@@ -108,6 +108,20 @@ TestCase tests[] = {
TEST_ENTRY(test_async_scan_behavior),
TEST_ENTRY(test_scan_status_checking),
TEST_ENTRY(test_scan_completion_wait),
TEST_ENTRY(test_scan_cancels_when_connect_pending),
TEST_ENTRY(test_scan_cancels_when_lifecycle_blocked),
TEST_ENTRY(test_scan_generation_invalidated_on_reset),
// Template rendering tests
TEST_ENTRY(test_page_shell_supports_page_assets),
TEST_ENTRY(test_root_template_renders_core_placeholders),
TEST_ENTRY(test_wifi_template_renders_scan_container_and_form_fields),
TEST_ENTRY(test_message_template_renders_body_and_actions),
TEST_ENTRY(test_param_template_renders_shared_actions),
TEST_ENTRY(test_info_template_renders_sections_and_footer),
TEST_ENTRY(test_fragment_action_form_renders_shared_button_markup),
TEST_ENTRY(test_fragment_info_row_and_section_render),
TEST_ENTRY(test_fragment_centered_button_supports_extra_attrs),
// State transition tests
TEST_ENTRY(test_portal_to_connected_transition),
+14
View File
@@ -118,6 +118,20 @@ void test_wifi_scan_initiates();
void test_async_scan_behavior();
void test_scan_status_checking();
void test_scan_completion_wait();
void test_scan_cancels_when_connect_pending();
void test_scan_cancels_when_lifecycle_blocked();
void test_scan_generation_invalidated_on_reset();
// Template rendering tests
void test_page_shell_supports_page_assets();
void test_root_template_renders_core_placeholders();
void test_wifi_template_renders_scan_container_and_form_fields();
void test_message_template_renders_body_and_actions();
void test_param_template_renders_shared_actions();
void test_info_template_renders_sections_and_footer();
void test_fragment_action_form_renders_shared_button_markup();
void test_fragment_info_row_and_section_render();
void test_fragment_centered_button_supports_extra_attrs();
// State transition tests
void test_portal_to_connected_transition();
@@ -65,17 +65,18 @@ void test_set_and_get_hostname() {
WiFiManager wm;
// Test with char*
wm.setHostname("test-hostname");
String hostname1 = wm.getWiFiHostname();
// Verify hostname was set (may be empty initially, but getter doesn't crash)
TEST_ASSERT_TRUE_MESSAGE(true, "setHostname(char*) and getWiFiHostname() executed");
// Test with String
wm.setHostname(String("test-hostname-2"));
String hostname2 = wm.getWiFiHostname();
// Verify getter works
TEST_ASSERT_TRUE_MESSAGE(true, "setHostname(String) and getWiFiHostname() executed");
TEST_ASSERT_TRUE_MESSAGE(wm.setHostname("test-hostname"), "Valid char* hostname should be accepted");
TEST_ASSERT_TRUE_MESSAGE(wm.setHostname(String(" trimmed-hostname ")), "Valid String hostname should be trimmed and accepted");
TEST_ASSERT_TRUE_MESSAGE(wm.setHostname(""), "Empty hostname should clear the configured hostname");
String tooLongHostname = "123456789012345678901234567890123";
TEST_ASSERT_FALSE_MESSAGE(wm.setHostname(tooLongHostname), "Hostnames longer than 32 chars should be rejected");
TEST_ASSERT_FALSE_MESSAGE(wm.setHostname("bad host"), "Hostnames with spaces should be rejected");
TEST_ASSERT_FALSE_MESSAGE(wm.setHostname("-leading"), "Hostnames cannot start with a hyphen");
TEST_ASSERT_FALSE_MESSAGE(wm.setHostname("trailing-"), "Hostnames cannot end with a hyphen");
(void)wm.getWiFiHostname();
TEST_ASSERT_TRUE_MESSAGE(true, "getWiFiHostname() executed after hostname validation checks");
Serial.println("[TEST] setHostname() and getWiFiHostname() test completed successfully");
}
@@ -0,0 +1,251 @@
#include <unity.h>
#include <Arduino.h>
#include <TemplateEngine.h>
#include "templates/Fragments.h"
#include "templates/Info.h"
#include "templates/Param.h"
#include "templates/PageShell.h"
#include "templates/Root.h"
#include "templates/WiFi.h"
#include "templates/Message.h"
#include "../test_main.h"
namespace {
String renderTemplate(const char* templateData, PlaceholderRegistry& registry) {
TemplateContext context;
context.setRegistry(&registry);
TemplateRenderer::initializeContext(context, templateData);
uint8_t buffer[128];
String output;
while (true) {
size_t written = TemplateRenderer::renderNextChunk(context, buffer, sizeof(buffer));
if (written == 0) {
break;
}
output.concat(reinterpret_cast<const char*>(buffer), written);
}
return output;
}
const char kEmpty[] PROGMEM = "";
const char kDocTitle[] PROGMEM = "Config ESP";
const char kBodyClass[] PROGMEM = "wifi";
const char kPageScripts[] PROGMEM = "<script>console.log('wifi page');</script>";
const char kPageStyles[] PROGMEM = "<style>.wifi{color:#000;}</style>";
const char kPageContent[] PROGMEM = "<div>Body</div>";
const char kScanContent[] PROGMEM = "Scanning for networks...<br/><br/>";
const char kSsidPlaceholder[] PROGMEM = "Office";
const char kPasswordPlaceholder[] PROGMEM = "********";
const char kStaticFields[] PROGMEM = "<hr><br/><label for='ip'>Static IP</label>";
const char kParamSection[] PROGMEM = "<hr><br/><label for='param_0'>Token</label>";
const char kFormActions[] PROGMEM = "<br/><br/><button type='submit'>Save</button>";
const char kPageActions[] PROGMEM = "<br/><div class='c'><button id='refresh-btn' type='button' onclick='return refreshScan()'>Refresh</button></div><hr><br/><form action='/' method='get'><button>Back</button></form>";
const char kMessageActions[] PROGMEM = "<hr><br/><form action='/' method='get'><button>Back</button></form>";
const char kStatus[] PROGMEM = "<div class='msg'>Status</div>";
const char kMessageBody[] PROGMEM = "<div class='msg S'>Saved</div>";
const char kInfoSection[] PROGMEM = "<h3>About</h3><hr><dl><dt>Build date</dt><dd>today</dd></dl>";
const char kInfoFooter[] PROGMEM = "<hr><br/><form action='/update' method='get'><button>Update</button></form>";
const char kPageTitle[] PROGMEM = "WiFiManager";
const char kSubtitle[] PROGMEM = "Setup";
const char kMenu[] PROGMEM = "<form><button>Configure WiFi</button></form>";
const char kActionPrefix[] PROGMEM = "<hr><br/>";
const char kActionPath[] PROGMEM = "/update";
const char kActionMethod[] PROGMEM = "get";
const char kActionLabel[] PROGMEM = "Update";
const char kActionClassAttr[] PROGMEM = " class='D'";
const char kActionSuffix[] PROGMEM = "<br/>\n";
const char kButtonId[] PROGMEM = "refresh-btn";
const char kButtonType[] PROGMEM = "button";
const char kButtonOnClick[] PROGMEM = "return refreshScan()";
const char kButtonExtraAttrs[] PROGMEM = " data-skip-initial-scan='true'";
const char kInfoLabel[] PROGMEM = "SDK version";
const char kInfoValue[] PROGMEM = "3.1.2";
const char kSectionTitle[] PROGMEM = "Device";
const char kSectionRows[] PROGMEM = "<dt>Chip ID</dt><dd>abc</dd>";
} // namespace
void test_page_shell_supports_page_assets() {
Serial.println("[TEST] Testing page shell page-scoped assets...");
PlaceholderRegistry registry(12);
registry.registerProgmemData("%SCRIPTS%", kEmpty);
registry.registerProgmemData("%PAGE_SCRIPTS%", kPageScripts);
registry.registerProgmemData("%STYLES%", kEmpty);
registry.registerProgmemData("%PAGE_STYLES%", kPageStyles);
registry.registerProgmemData("%DOC_TITLE%", kDocTitle);
registry.registerProgmemData("%BODY_CLASS%", kBodyClass);
registry.registerProgmemData("%PAGE_CONTENT%", kPageContent);
String output = renderTemplate(WM_PAGE_SHELL_TEMPLATE, registry);
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("wifi page"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf(".wifi{color:#000;}"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("<div>Body</div>"));
Serial.println("[TEST] Page shell page-scoped assets test completed successfully");
}
void test_wifi_template_renders_scan_container_and_form_fields() {
Serial.println("[TEST] Testing WiFi page template rendering...");
PlaceholderRegistry registry(16);
registry.registerProgmemData("%WIFI_SCAN_CONTENT%", kScanContent);
registry.registerProgmemData("%WIFI_SSID_PLACEHOLDER%", kSsidPlaceholder);
registry.registerProgmemData("%WIFI_PASSWORD_PLACEHOLDER%", kPasswordPlaceholder);
registry.registerProgmemData("%WIFI_STATIC_FIELDS%", kStaticFields);
registry.registerProgmemData("%WIFI_PARAM_SECTION%", kParamSection);
registry.registerProgmemData("%WIFI_FORM_ACTIONS%", kFormActions);
registry.registerProgmemData("%WIFI_PAGE_ACTIONS%", kPageActions);
registry.registerProgmemData("%WIFI_STATUS%", kStatus);
String output = renderTemplate(WM_WIFI_CONTENT_TEMPLATE, registry);
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("id='scan-results'"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Scanning for networks..."));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("placeholder='Office'"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("id='refresh-btn'"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Token"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Save"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Back"));
Serial.println("[TEST] WiFi page template rendering test completed successfully");
}
void test_message_template_renders_body_and_actions() {
Serial.println("[TEST] Testing message template rendering...");
PlaceholderRegistry registry(8);
registry.registerProgmemData("%MESSAGE_BODY%", kMessageBody);
registry.registerProgmemData("%MESSAGE_ACTIONS%", kMessageActions);
String output = renderTemplate(WM_MESSAGE_CONTENT_TEMPLATE, registry);
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Saved"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Back"));
Serial.println("[TEST] Message template rendering test completed successfully");
}
void test_param_template_renders_shared_actions() {
Serial.println("[TEST] Testing param page template rendering...");
PlaceholderRegistry registry(8);
registry.registerProgmemData("%PARAM_FIELDS%", kParamSection);
registry.registerProgmemData("%PARAM_FORM_ACTIONS%", kFormActions);
registry.registerProgmemData("%PARAM_PAGE_ACTIONS%", kMessageActions);
registry.registerProgmemData("%PARAM_STATUS%", kStatus);
String output = renderTemplate(WM_PARAM_CONTENT_TEMPLATE, registry);
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Token"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Save"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Back"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Status"));
Serial.println("[TEST] Param page template rendering test completed successfully");
}
void test_info_template_renders_sections_and_footer() {
Serial.println("[TEST] Testing info page template rendering...");
PlaceholderRegistry registry(8);
registry.registerProgmemData("%INFO_STATUS%", kStatus);
registry.registerProgmemData("%INFO_DEVICE_SECTION%", kInfoSection);
registry.registerProgmemData("%INFO_WIFI_SECTION%", kInfoSection);
registry.registerProgmemData("%INFO_ABOUT_SECTION%", kInfoSection);
registry.registerProgmemData("%INFO_FOOTER%", kInfoFooter);
String output = renderTemplate(WM_INFO_CONTENT_TEMPLATE, registry);
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Status"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Build date"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Update"));
Serial.println("[TEST] Info page template rendering test completed successfully");
}
void test_fragment_action_form_renders_shared_button_markup() {
Serial.println("[TEST] Testing shared action fragment rendering...");
PlaceholderRegistry registry(8);
registry.registerProgmemData("%ACTION_PREFIX%", kActionPrefix);
registry.registerProgmemData("%ACTION%", kActionPath);
registry.registerProgmemData("%METHOD%", kActionMethod);
registry.registerProgmemData("%BUTTON_LABEL%", kActionLabel);
registry.registerProgmemData("%BUTTON_CLASS_ATTR%", kActionClassAttr);
registry.registerProgmemData("%ACTION_SUFFIX%", kActionSuffix);
String output = renderTemplate(WM_ACTION_FORM_TEMPLATE, registry);
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("action='/update'"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("class='D'"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf(">Update</button>"));
Serial.println("[TEST] Shared action fragment rendering test completed successfully");
}
void test_fragment_info_row_and_section_render() {
Serial.println("[TEST] Testing shared info fragment rendering...");
PlaceholderRegistry rowRegistry(4);
rowRegistry.registerProgmemData("%INFO_LABEL%", kInfoLabel);
rowRegistry.registerProgmemData("%INFO_VALUE%", kInfoValue);
String rowOutput = renderTemplate(WM_INFO_ROW_TEMPLATE, rowRegistry);
TEST_ASSERT_NOT_EQUAL(-1, rowOutput.indexOf("<dt>SDK version</dt><dd>3.1.2</dd>"));
PlaceholderRegistry sectionRegistry(6);
sectionRegistry.registerProgmemData("%SECTION_PREFIX%", kEmpty);
sectionRegistry.registerProgmemData("%SECTION_TITLE%", kSectionTitle);
sectionRegistry.registerProgmemData("%SECTION_ROWS%", kSectionRows);
sectionRegistry.registerProgmemData("%SECTION_SUFFIX%", kActionSuffix);
String sectionOutput = renderTemplate(WM_INFO_SECTION_TEMPLATE, sectionRegistry);
TEST_ASSERT_NOT_EQUAL(-1, sectionOutput.indexOf("<h3>Device</h3>"));
TEST_ASSERT_NOT_EQUAL(-1, sectionOutput.indexOf("<dt>Chip ID</dt><dd>abc</dd>"));
Serial.println("[TEST] Shared info fragment rendering test completed successfully");
}
void test_fragment_centered_button_supports_extra_attrs() {
Serial.println("[TEST] Testing centered button fragment extra attrs...");
PlaceholderRegistry registry(8);
registry.registerProgmemData("%BUTTON_ID%", kButtonId);
registry.registerProgmemData("%BUTTON_TYPE%", kButtonType);
registry.registerProgmemData("%BUTTON_ONCLICK%", kButtonOnClick);
registry.registerProgmemData("%BUTTON_LABEL%", kActionLabel);
registry.registerProgmemData("%BUTTON_EXTRA_ATTRS%", kButtonExtraAttrs);
String output = renderTemplate(WM_CENTERED_BUTTON_TEMPLATE, registry);
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("id='refresh-btn'"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("data-skip-initial-scan='true'"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf(">Update</button>"));
Serial.println("[TEST] Centered button fragment extra attrs test completed successfully");
}
void test_root_template_renders_core_placeholders() {
Serial.println("[TEST] Testing root template rendering...");
PlaceholderRegistry registry(8);
registry.registerProgmemData("%SCRIPTS%", kEmpty);
registry.registerProgmemData("%STYLES%", kEmpty);
registry.registerProgmemData("%PAGE_TITLE%", kPageTitle);
registry.registerProgmemData("%SUBTITLE%", kSubtitle);
registry.registerProgmemData("%MENU%", kMenu);
registry.registerProgmemData("%STATUS%", kStatus);
String output = renderTemplate(WM_ROOT_TEMPLATE, registry);
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("WiFiManager"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Setup"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Configure WiFi"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("Status"));
Serial.println("[TEST] Root template rendering test completed successfully");
}
@@ -3,88 +3,150 @@
#include <WiFiManager.h>
#include "../test_main.h"
// Test WiFi scan initiates
void test_wifi_scan_initiates() {
Serial.println("[TEST] Testing WiFi scan initiation...");
Serial.println("[TEST] Testing async scan request queues...");
WiFiManager wm;
// Initiate scan (non-blocking)
unsigned long start = millis();
(void)WiFi.scanNetworks(true); // true = async
unsigned long elapsed = millis() - start;
// Scan initiation should return quickly (non-blocking)
TEST_ASSERT_LESS_THAN(100, elapsed);
// Return value should be number of networks (if scan completed) or -1 (if async)
// For async scan, it returns -1 immediately
TEST_ASSERT_TRUE_MESSAGE(true, "WiFi scan initiated (return value may be -1 for async)");
Serial.println("[TEST] WiFi scan initiation test completed successfully");
wm.requestAsyncScan(true);
TEST_ASSERT_EQUAL(WiFiManager::WM_SCAN_QUEUED, wm.getScanState());
TEST_ASSERT_TRUE_MESSAGE(wm.getScanSnapshot().schedulePending,
"Explicit refresh should mark scan scheduling as pending");
TEST_ASSERT_EQUAL(WiFiManager::WM_SCAN_SCHEDULE_USER_REFRESH,
wm.getScanSnapshot().scheduledReason);
TEST_ASSERT_FALSE_MESSAGE(wm.hasValidScanResults(),
"Queueing a scan should invalidate previous cached results");
Serial.println("[TEST] Async scan request queues test completed successfully");
}
// Test async scan behavior
void test_async_scan_behavior() {
Serial.println("[TEST] Testing async scan behavior...");
Serial.println("[TEST] Testing repeated scan requests coalesce...");
WiFiManager wm;
// Initiate async scan
unsigned long start = millis();
(void)WiFi.scanNetworks(true); // true = async
unsigned long elapsed = millis() - start;
// Should return immediately (non-blocking)
TEST_ASSERT_LESS_THAN(50, elapsed);
// For async scan, result is -1 immediately
// Scan is in progress, status can be checked later
TEST_ASSERT_TRUE_MESSAGE(true, "Async scan returns immediately (non-blocking)");
Serial.println("[TEST] Async scan behavior test completed successfully");
wm.requestAsyncScan(true);
wm.requestAsyncScan(true);
wm.requestAsyncScan(true);
TEST_ASSERT_EQUAL(WiFiManager::WM_SCAN_QUEUED, wm.getScanState());
TEST_ASSERT_TRUE_MESSAGE(wm.getScanSnapshot().schedulePending,
"Repeated refresh clicks should still leave one pending schedule");
TEST_ASSERT_EQUAL(WiFiManager::WM_SCAN_SCHEDULE_USER_REFRESH,
wm.getScanSnapshot().scheduledReason);
TEST_ASSERT_FALSE_MESSAGE(wm.hasValidScanResults(),
"Repeated refresh requests should keep a single queued scan lifecycle");
Serial.println("[TEST] Repeated scan requests coalesce test completed successfully");
}
// Test scan status checking (non-blocking)
void test_scan_status_checking() {
Serial.println("[TEST] Testing scan status checking...");
Serial.println("[TEST] Testing cached scan snapshot state...");
WiFiManager wm;
// Initiate scan
WiFi.scanNetworks(true); // async
// Check scan status (should be able to check without blocking)
(void)WiFi.scanComplete();
// Status may be -1 (scanning), -2 (not started), or >= 0 (number of networks)
// Just verify we can check status without blocking
TEST_ASSERT_TRUE_MESSAGE(true, "Scan status can be checked without blocking");
Serial.println("[TEST] Scan status checking test completed successfully");
#ifdef UNIT_TEST
std::vector<WiFiManager::WiFiScanNetwork> results = {
{ "Office", -48, 1 },
{ "Guest", -70, 0 }
};
wm.wmTestInjectScanResults(results);
TEST_ASSERT_TRUE_MESSAGE(wm.hasValidScanResults(),
"Injected scan snapshot should be marked valid");
TEST_ASSERT_EQUAL(WiFiManager::WM_SCAN_COMPLETE, wm.getScanState());
TEST_ASSERT_EQUAL(static_cast<size_t>(2), wm.getScanResults().size());
TEST_ASSERT_EQUAL_STRING("Office", wm.getScanResults()[0].ssid.c_str());
#else
TEST_ASSERT_TRUE_MESSAGE(true, "UNIT_TEST helpers unavailable");
#endif
Serial.println("[TEST] Cached scan snapshot state test completed successfully");
}
// Test scan completion wait
void test_scan_completion_wait() {
Serial.println("[TEST] Testing scan completion wait...");
Serial.println("[TEST] Testing scan timeout transition...");
WiFiManager wm;
// Initiate scan
WiFi.scanNetworks(true); // async
// Wait for scan to complete (with timeout)
unsigned long start = millis();
int scanStatus = -1;
while (scanStatus < 0 && (millis() - start < 10000)) { // 10 second timeout
delay(100);
scanStatus = WiFi.scanComplete();
}
// After wait, status should be >= 0 (completed) or still -1 (timeout)
// Just verify we can wait for completion
TEST_ASSERT_TRUE_MESSAGE(true, "Scan completion can be waited for");
Serial.println("[TEST] Scan completion wait test completed successfully");
#ifdef UNIT_TEST
wm.wmTestSetPortalActive(true);
wm.wmTestForceScanState(WiFiManager::WM_SCAN_RUNNING);
wm.wmTestSetScanStartedAt(millis() - 20000);
wm.wmTestSetScanTimeoutMs(1000);
wm.process();
TEST_ASSERT_EQUAL(WiFiManager::WM_SCAN_TIMEOUT, wm.getScanState());
TEST_ASSERT_FALSE_MESSAGE(wm.hasValidScanResults(),
"Timed out scans should not leave cached results marked valid");
#else
TEST_ASSERT_TRUE_MESSAGE(true, "UNIT_TEST helpers unavailable");
#endif
Serial.println("[TEST] Scan timeout transition test completed successfully");
}
void test_scan_cancels_when_connect_pending() {
Serial.println("[TEST] Testing scan cancellation during connect...");
WiFiManager wm;
#ifdef UNIT_TEST
wm.wmTestSetPortalActive(true);
wm.wmTestForceScanState(WiFiManager::WM_SCAN_RUNNING);
wm.wmTestSetScanStartedAt(millis());
wm.wmTestSetConnectPending(true);
wm.process();
TEST_ASSERT_EQUAL(WiFiManager::WM_SCAN_IDLE, wm.getScanState());
TEST_ASSERT_FALSE_MESSAGE(wm.getScanSnapshot().schedulePending,
"Connect flow should cancel any in-flight async scan");
#else
TEST_ASSERT_TRUE_MESSAGE(true, "UNIT_TEST helpers unavailable");
#endif
Serial.println("[TEST] Scan cancellation during connect test completed successfully");
}
void test_scan_cancels_when_lifecycle_blocked() {
Serial.println("[TEST] Testing scan cancellation during lifecycle block...");
WiFiManager wm;
#ifdef UNIT_TEST
wm.wmTestSetPortalActive(true);
wm.requestAsyncScan(true);
wm.wmTestSetScanLifecycleBlocked(true);
wm.process();
TEST_ASSERT_EQUAL(WiFiManager::WM_SCAN_IDLE, wm.getScanState());
TEST_ASSERT_FALSE_MESSAGE(wm.getScanSnapshot().schedulePending,
"Lifecycle blocking should leave scan engine idle");
#else
TEST_ASSERT_TRUE_MESSAGE(true, "UNIT_TEST helpers unavailable");
#endif
Serial.println("[TEST] Scan cancellation during lifecycle block test completed successfully");
}
void test_scan_generation_invalidated_on_reset() {
Serial.println("[TEST] Testing scan generation invalidation on reset...");
WiFiManager wm;
#ifdef UNIT_TEST
wm.wmTestSetScanGenerations(4, 4, 4);
wm.wmTestSetScanCompletionPending(3);
wm.wmTestClearScanResults();
TEST_ASSERT_EQUAL(WiFiManager::WM_SCAN_IDLE, wm.getScanState());
TEST_ASSERT_TRUE_MESSAGE(wm.getScanSnapshot().generation > 4,
"Resetting scan state should invalidate older completion generations");
TEST_ASSERT_FALSE_MESSAGE(wm.getScanSnapshot().completionPending,
"Resetting scan state should clear pending completion callbacks");
#else
TEST_ASSERT_TRUE_MESSAGE(true, "UNIT_TEST helpers unavailable");
#endif
Serial.println("[TEST] Scan generation invalidation on reset test completed successfully");
}