Improve portal WiFi handoff and bump WiFiManager to v2.0.19.

Expose redirect details after connect-on-save so clients can land on the station IP before the AP closes, hide the mobile nav scrollbar, and document the updated API contract.
This commit is contained in:
2026-05-05 00:23:22 +10:00
parent 7735d7f0cb
commit 396a6e07db
10 changed files with 380 additions and 81 deletions
+24
View File
@@ -32,6 +32,7 @@ This fork currently includes the following architectural improvements:
- **Capability-driven UI flags** in bootstrap/API payloads so features like info, update, erase, and action visibility can be controlled by backend state.
- **Portal bootstrap contract v2**: nested `brand`, `context`, `pages`, `actions`, `layout`, `extraHomeCards`; Wi-Fi meta params include `kind` (`field` | `html`) for first-class custom HTML parameters.
- **SPA-native feedback UX** using in-DOM dialog/toast behavior rather than page-based action flows.
- **Connect-on-save handoff**: when portal save triggers a station connect, `/api/wifi/connect-status` now reports a brief success state with `stationIp` and `redirectUrl` so the SPA can show the new address and redirect before the AP shuts down.
- **Request-scoped shell rendering**: the root portal page is built for each `GET /` from `WM_ROOT_SHELL_TEMPLATE` using a fresh placeholder registry. Shell inputs are `%PAGE_TITLE%`, `%STYLES%`, `%BOOTSTRAP_JSON%`, `%PORTAL_APP_JS%`, and `%PORTAL_APPEND_JS%` — filled in `WiFiManagerHandlers` from WiFiManager state and embedded assets (not from a server-wide template registry).
- **Customization via WiFiManager `portal*` APIs** (`portalSetBrandTitle`, `portalSetPageInfoVisible`, `portalSetLayoutParamsLocation`, `portalAddParameter`, asset hooks, etc.) and JSON under `/api/...`, not by exposing placeholder-registry mutation to consumers.
- A clearer separation between:
@@ -140,6 +141,29 @@ wm.portalAppendJs(
);
```
## WiFi Connect Status API
When `portalSetBehaviorConnectOnSave(true)` is enabled, saving WiFi credentials queues a station join and the SPA polls `GET /api/wifi/connect-status`.
Response shape:
```json
{
"state": "waiting | success | failed",
"message": "human readable status",
"wifiStatus": "WL_CONNECTED",
"stationIp": "192.168.1.42",
"redirectUrl": "http://192.168.1.42/"
}
```
Notes:
- `stationIp` and `redirectUrl` are present only after a successful station join.
- If the portal HTTP server is not on port `80`, `redirectUrl` includes the active port.
- On success, WiFiManager keeps the portal alive briefly so the client can read the success payload and navigate to the new device address before the captive AP is shut down.
- This improves the handoff on typical home networks, but it is not a universal guarantee: client captive-portal helpers, browser behavior, DHCP timing, and network isolation can still affect whether the redirect completes automatically.
## Dependencies
This fork depends on **DFTE** ([Device Framework Template Engine](https://github.com/alexhopeoconnor/DFTE)) and **ESP32Async/ESPAsyncWebServer**.
+24 -2
View File
@@ -147,7 +147,7 @@
#define WM_STRING(x) WM_STRING2(x)
// WiFiManager version
const char WM_VERSION_STR[] PROGMEM = "v2.0.18";
const char WM_VERSION_STR[] PROGMEM = "v2.0.19";
// #include <esp_idf_version.h>
#ifdef ESP_IDF_VERSION
@@ -965,7 +965,26 @@ protected:
_scan.completionPending = true;
_scan.completionResult = completionResult;
}
void wmTestSetPortalActive(bool active) { configPortalActive = active; }
void wmTestSetPortalActive(bool active) {
configPortalActive = active;
// Keep the test helper aligned with real portal startup so timeout-based
// assertions are anchored to "now" instead of device boot.
if (active) {
_configPortalStart = millis();
}
}
void wmTestSetPortalConnectSuccess(const String& message, const String& stationIp, uint8_t status = WL_CONNECTED) {
_cpConnectState = wm_cp_connect_state_t::success;
_cpConnectMessage = message;
_cpConnectStationIp = stationIp;
_cpConnectStatus = status;
}
void wmTestSetPortalConnectFailure(const String& message, uint8_t status = WL_CONNECT_FAILED) {
_cpConnectState = wm_cp_connect_state_t::failed;
_cpConnectMessage = message;
_cpConnectStationIp = "";
_cpConnectStatus = status;
}
void wmTestSetConnectPending(bool active);
void wmTestSetScanLifecycleBlocked(bool blocked) { _scanLifecycleBlocked = blocked; }
void wmTestSetScanGenerations(uint32_t generation, uint32_t runningGeneration, uint32_t completionGeneration) {
@@ -987,6 +1006,7 @@ protected:
delaying,
starting,
waiting,
success_waiting_close,
success,
failed
};
@@ -1001,6 +1021,8 @@ protected:
unsigned long _cpConnectStartedAt = 0;
unsigned long _cpConnectDelayUntil = 0;
unsigned long _cpConnectTimeoutMs = 0;
unsigned long _cpConnectCloseAt = 0;
String _cpConnectStationIp;
WiFiManagerEventCallback _eventCallback = nullptr;
//helpers (rendering methods moved to WiFiManagerHandlers)
+23 -5
View File
@@ -57,15 +57,22 @@ const char CSS_STYLE[] PROGMEM = "<style>"
"h2{font-size:1.15rem}"
"h3{font-size:1rem}"
"p{margin:.5em 0}"
/* Top navigation */
".wm-nav{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 20px;padding:6px;"
/* Top navigation — single row; overflow scrollable but scrollbar hidden (touch still pans) */
".wm-nav{display:flex;flex-wrap:nowrap;align-items:stretch;gap:4px;margin:0 0 20px;padding:4px 6px;"
"overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;-ms-overflow-style:none;"
"background:var(--wm-surface);border-radius:var(--wm-radius);box-shadow:var(--wm-shadow);border:1px solid var(--wm-border)}"
".wm-nav-link{display:inline-flex;align-items:center;justify-content:center;padding:10px 14px;border-radius:var(--wm-radius-sm);"
"font-size:.9rem;font-weight:600;color:var(--wm-muted);text-decoration:none;border:1px solid transparent}"
".wm-nav::-webkit-scrollbar{display:none;width:0;height:0}"
".wm-nav-link{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;padding:6px 8px;border-radius:var(--wm-radius-sm);"
"font-size:.8rem;font-weight:600;color:var(--wm-muted);text-decoration:none;border:1px solid transparent;white-space:nowrap}"
".wm-nav-link:hover{color:var(--wm-brand);background:rgba(14,165,233,.08);text-decoration:none}"
"body.invert .wm-nav-link:hover{background:rgba(56,189,248,.12)}"
".wm-nav-link--active{color:var(--wm-text);background:rgba(14,165,233,.15);border-color:rgba(14,165,233,.35)}"
"body.invert .wm-nav-link--active{background:rgba(56,189,248,.2);border-color:rgba(56,189,248,.4)}"
".wm-nav .wm-icon-label{gap:4px}"
".wm-nav .wm-icon{width:.95rem;height:.95rem;flex:0 0 .95rem}"
".wm-icon-label{display:inline-flex;align-items:center;gap:8px;min-width:0}"
".wm-icon{display:inline-flex;align-items:center;justify-content:center;width:1.1rem;height:1.1rem;flex:0 0 1.1rem;color:currentColor}"
".wm-icon svg{display:block;width:100%;height:100%}"
/* Hero / page chrome */
".wm-hero{margin-bottom:20px}"
".wm-hero-title{font-size:1.65rem;line-height:1.2;margin-bottom:.25em}"
@@ -81,6 +88,7 @@ const char CSS_STYLE[] PROGMEM = "<style>"
"box-shadow:var(--wm-shadow);padding:18px 16px;margin-bottom:16px;text-align:left}"
".wm-card-title{font-size:1rem;margin:0 0 12px;padding-bottom:10px;border-bottom:1px solid var(--wm-border);"
"color:var(--wm-text)}"
".wm-card-subtitle{margin:-4px 0 12px;font-size:.9rem;line-height:1.45;color:var(--wm-muted)}"
".wm-lead{margin:0 0 8px;font-size:.95rem;color:var(--wm-muted);line-height:1.5}"
".wm-home-summary{margin:0 0 14px;font-size:1rem;font-weight:600;line-height:1.45;color:var(--wm-text)}"
/* Callout / status */
@@ -88,7 +96,16 @@ const char CSS_STYLE[] PROGMEM = "<style>"
"background:rgba(14,165,233,.06);margin-bottom:16px}"
"body.invert .wm-callout{background:rgba(56,189,248,.08)}"
".wm-callout--info{border-left:4px solid var(--wm-brand)}"
".wm-status{margin:0 0 10px;font-size:.9rem;font-weight:600;color:var(--wm-brand)}"
".wm-info-summary{font-weight:600;color:var(--wm-text)}"
".wm-status{margin:0 0 12px;padding:10px 12px;font-size:.92rem;font-weight:700;color:var(--wm-brand);"
"text-align:center;border-radius:var(--wm-radius-sm);background:rgba(14,165,233,.08);border:1px solid rgba(14,165,233,.18)}"
".wm-status--warning{color:#c2410c;background:rgba(249,115,22,.12);border-color:rgba(249,115,22,.28)}"
".wm-status--caution{color:#a16207;background:rgba(234,179,8,.14);border-color:rgba(234,179,8,.3)}"
".wm-status--danger{color:var(--wm-danger);background:rgba(220,38,38,.12);border-color:rgba(220,38,38,.28)}"
"body.invert .wm-status{background:rgba(56,189,248,.12);border-color:rgba(56,189,248,.22)}"
"body.invert .wm-status--warning{background:rgba(249,115,22,.18);border-color:rgba(249,115,22,.34)}"
"body.invert .wm-status--caution{background:rgba(234,179,8,.18);border-color:rgba(234,179,8,.34)}"
"body.invert .wm-status--danger{background:rgba(220,38,38,.18);border-color:rgba(220,38,38,.34)}"
/* Form fields */
".wm-field{margin-bottom:14px;text-align:left}"
".wm-field:last-child{margin-bottom:0}"
@@ -109,6 +126,7 @@ const char CSS_STYLE[] PROGMEM = "<style>"
".wm-btn:disabled{opacity:.5;cursor:not-allowed}"
".wm-btn:active{transform:scale(.98)}"
"a.wm-btn{box-sizing:border-box;text-decoration:none}"
".wm-btn .wm-icon-label{justify-content:center}"
".wm-btn--block{width:100%}"
".wm-btn--primary{background:var(--wm-brand);color:#fff}"
".wm-btn--primary:hover{background:var(--wm-brand-hover)}"
+192 -48
View File
@@ -106,8 +106,15 @@ function startPortalTimeoutCountdown(initialSeconds){
var el=$('wm-portal-timeout');
var remaining=Math.max(0,Math.floor(initialSeconds||0));
if(!el||remaining<=0)return;
function levelClass(seconds){
if(seconds<10)return'wm-status--danger';
if(seconds<30)return'wm-status--caution';
if(seconds<60)return'wm-status--warning';
return'';
}
function render(){
if(!el)return;
el.className='wm-status'+(levelClass(remaining)?' '+levelClass(remaining):'');
el.textContent='Captive portal timeout: '+formatDurationSeconds(remaining);
}
render();
@@ -150,6 +157,104 @@ function renderFieldList(arr){
return h;
}
function iconSvg(name){
var body='';
switch(name){
case 'home':
body="<path d='M3 10.5 12 3l9 7.5'/><path d='M5 9.5V20h14V9.5'/>";
break;
case 'wifi':
body="<path d='M4.5 9.5a12 12 0 0 1 15 0'/><path d='M7.5 12.5a7.5 7.5 0 0 1 9 0'/><path d='M10.5 15.5a3.5 3.5 0 0 1 3 0'/><circle cx='12' cy='19' r='1.2' fill='currentColor' stroke='none'/>";
break;
case 'setup':
body="<line x1='5' y1='6' x2='19' y2='6'/><circle cx='9' cy='6' r='2'/><line x1='5' y1='12' x2='19' y2='12'/><circle cx='15' cy='12' r='2'/><line x1='5' y1='18' x2='19' y2='18'/><circle cx='11' cy='18' r='2'/>";
break;
case 'info':
body="<circle cx='12' cy='12' r='9'/><line x1='12' y1='10.5' x2='12' y2='16.5'/><circle cx='12' cy='7.2' r='1' fill='currentColor' stroke='none'/>";
break;
case 'update':
body="<path d='M12 4v10'/><path d='m8.5 10.5 3.5 3.5 3.5-3.5'/><path d='M5 19h14'/>";
break;
case 'back':
body="<path d='M10 6 4 12l6 6'/><path d='M4 12h16'/>";
break;
case 'restart':
body="<path d='M20 12a8 8 0 1 1-2.3-5.7'/><path d='M20 4v5h-5'/>";
break;
case 'exit':
body="<path d='M10 6H6v12h4'/><path d='M14 8l4 4-4 4'/><path d='M9 12h9'/>";
break;
case 'close':
body="<path d='M4 12a8 8 0 1 0 16 0A8 8 0 1 0 4 12'/><path d='m8.5 8.5 7 7'/>";
break;
case 'erase':
body="<path d='M5 7h14'/><path d='M9 7V5h6v2'/><path d='M8 7l1 12h6l1-12'/><path d='M10 10v6'/><path d='M14 10v6'/>";
break;
case 'status':
body="<circle cx='12' cy='12' r='9'/><path d='M12 7v5l3 2'/>";
break;
case 'device':
body="<rect x='7' y='7' width='10' height='10' rx='1.5'/><path d='M9 1v4M15 1v4M9 19v4M15 19v4M1 9h4M1 15h4M19 9h4M19 15h4'/>";
break;
case 'diagnostics':
body="<path d='M4 17h3l2-5 3 3 2-7 2 9h4'/>";
break;
case 'about':
body="<circle cx='12' cy='12' r='9'/><path d='M12 10.5v5.5'/><circle cx='12' cy='7.2' r='1' fill='currentColor' stroke='none'/>";
break;
case 'actions':
body="<path d='M12 3v18'/><path d='M3 12h18'/><path d='m6.5 6.5 11 11'/><path d='m17.5 6.5-11 11'/>";
break;
case 'scan':
body="<path d='M4 7h16'/><path d='M4 12h10'/><path d='M4 17h7'/><path d='m16 15 4 4'/><circle cx='14' cy='13' r='4'/>";
break;
case 'save':
body="<path d='M5 4h11l3 3v13H5z'/><path d='M8 4v5h8'/><path d='M9 19v-5h6v5'/>";
break;
case 'connect':
body="<path d='M4.5 12a7.5 7.5 0 0 1 7.5-7.5'/><path d='m17 6 2 2-2 2'/><path d='M12 4.5H19'/>";
break;
case 'upload':
body="<path d='M12 20V8'/><path d='m8.5 11.5 3.5-3.5 3.5 3.5'/><path d='M5 20h14'/>";
break;
}
if(!body)return'';
return "<span class='wm-icon wm-icon--"+name+"' aria-hidden='true'><svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'>"+body+"</svg></span>";
}
function labelWithIcon(name,label){
return "<span class='wm-icon-label'>"+iconSvg(name)+"<span>"+esc(label||'')+"</span></span>";
}
function cardTitleHtml(name,title){
return "<h2 class='wm-card-title'>"+labelWithIcon(name,title)+"</h2>";
}
function sectionCardHtml(name,title,subtitle,rows){
if(!rows||!rows.length)return'';
var h="<div class='wm-card'>"+cardTitleHtml(name,title);
if(subtitle)h+="<p class='wm-card-subtitle'>"+esc(subtitle)+"</p>";
h+="<dl class='wm-kv'>";
for(var i=0;i<rows.length;i++){
h+="<dt>"+esc(rows[i].label)+"</dt><dd>"+rows[i].value+"</dd>";
}
h+="</dl></div>";
return h;
}
function rowsWithoutKeys(rows,blocked){
if(!rows||!rows.length)return[];
var out=[],i,row,key;
for(i=0;i<rows.length;i++){
row=rows[i];
if(!row)continue;
key=row.key||'';
if(blocked.indexOf(key)!==-1)continue;
out.push(row);
}
return out;
}
function navBar(active){
active=active||'home';
var pages=boot.pages||{};
@@ -160,12 +265,12 @@ function navBar(active){
var pi=pages.info||{};
var pu=pages.update||{};
var h="<nav class='wm-nav' aria-label='Configuration'>";
h+="<a class='wm-nav-link"+(active==='home'?' wm-nav-link--active':'')+"' href='#/'>Home</a>";
h+="<a class='wm-nav-link"+(active==='wifi'?' wm-nav-link--active':'')+"' href='#/wifi'>WiFi</a>";
if(!pinWifi && ps.visible!==false){h+="<a class='wm-nav-link"+(active==='setup'?' wm-nav-link--active':'')+"' href='#/setup'>Setup</a>";}
if(pi.visible!==false){h+="<a class='wm-nav-link"+(active==='info'?' wm-nav-link--active':'')+"' href='#/info'>Info</a>";}
if(pu.visible){h+="<a class='wm-nav-link"+(active==='update'?' wm-nav-link--active':'')+"' href='#/update'>Update</a>";}
if(actions.back&&actions.back.visible){h+="<a href='#' class='wm-nav-link' id='wm-nav-back'>Back</a>";}
h+="<a class='wm-nav-link"+(active==='home'?' wm-nav-link--active':'')+"' href='#/'>"+labelWithIcon('home','Home')+"</a>";
h+="<a class='wm-nav-link"+(active==='wifi'?' wm-nav-link--active':'')+"' href='#/wifi'>"+labelWithIcon('wifi','WiFi')+"</a>";
if(!pinWifi && ps.visible!==false){h+="<a class='wm-nav-link"+(active==='setup'?' wm-nav-link--active':'')+"' href='#/setup'>"+labelWithIcon('setup','Setup')+"</a>";}
if(pi.visible!==false){h+="<a class='wm-nav-link"+(active==='info'?' wm-nav-link--active':'')+"' href='#/info'>"+labelWithIcon('info','Info')+"</a>";}
if(pu.visible){h+="<a class='wm-nav-link"+(active==='update'?' wm-nav-link--active':'')+"' href='#/update'>"+labelWithIcon('update','Update')+"</a>";}
if(actions.back&&actions.back.visible){h+="<a href='#' class='wm-nav-link' id='wm-nav-back'>"+labelWithIcon('back','Back')+"</a>";}
h+="</nav>";
return h;
}
@@ -173,11 +278,11 @@ function navBar(active){
function deviceActionsHtml(){
var actions=boot.actions||{};
var h='', ok=false;
if(actions.restart&&actions.restart.visible){h+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-restart-btn'>Restart device</button>";ok=true;}
if(actions.exitPortal&&actions.exitPortal.visible){h+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-exit-btn'>Exit portal</button>";ok=true;}
if(actions.closeCaptive&&actions.closeCaptive.visible){h+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-close-captive-btn'>Stop captive portal</button>";ok=true;}
if(actions.restart&&actions.restart.visible){h+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-restart-btn'>"+labelWithIcon('restart','Restart device')+"</button>";ok=true;}
if(actions.exitPortal&&actions.exitPortal.visible){h+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-exit-btn'>"+labelWithIcon('exit','Exit portal')+"</button>";ok=true;}
if(actions.closeCaptive&&actions.closeCaptive.visible){h+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-close-captive-btn'>"+labelWithIcon('close','Stop captive portal')+"</button>";ok=true;}
if(!ok)return'';
return "<div class='wm-card'><h2 class='wm-card-title'>Device</h2><div class='wm-btn-group'>"+h+"</div></div>";
return "<div class='wm-card'>"+cardTitleHtml('device','Device')+"<p class='wm-card-subtitle'>Quick device-level actions while the portal is open.</p><div class='wm-btn-group'>"+h+"</div></div>";
}
function renderExtraHomeCards(cards){
@@ -215,7 +320,7 @@ function viewHome(){
if(ident){html+="<p class='wm-hero-sub'>"+ident+"</p>";}
if(brand.homeIntro){html+="<p class='wm-hero-intro'>"+esc(brand.homeIntro)+"</p>";}
html+="</header>";
html+="<div class='wm-card'><h2 class='wm-card-title'>Status</h2>";
html+="<div class='wm-card'>"+cardTitleHtml('status','Status');
if(summary){html+="<p class='wm-home-summary'>"+esc(summary)+"</p>";}
if(timeoutSeconds>0){html+="<p class='wm-status' id='wm-portal-timeout'></p>";}
html+="<p class='wm-lead'>Use <strong>WiFi</strong> to scan networks and connect. Open <strong>Info</strong> for addresses and firmware actions.</p>";
@@ -316,6 +421,15 @@ function renderScanError(msg){
return "<div class='wm-scan-list'><p class='wm-lead' style='margin:0;padding:14px 12px'>"+esc(msg||'Failed to reach portal while scanning.')+"</p></div>";
}
function shouldAutoStartWifiScan(data){
if(!data)return true;
if(data.scanning)return false;
if((data.lastscan||0)>0)return false;
if(data.results_valid)return false;
if(data.error==='timeout' || data.error==='failed')return false;
return true;
}
function wifiRefresh(){
var box=getScanResultsBox();
if(!box)return;
@@ -360,7 +474,15 @@ function bindWifiViewEvents(){
if(el.tagName==='A' && el.getAttribute('data-ssid')){
ev.preventDefault();
var inp=$('wm-s');
var pass=$('wm-p');
if(inp)inp.value=el.getAttribute('data-ssid')||'';
if(pass){
if(pass.scrollIntoView)pass.scrollIntoView({behavior:'smooth',block:'center'});
if(pass.focus)pass.focus();
}else if(inp){
if(inp.scrollIntoView)inp.scrollIntoView({behavior:'smooth',block:'center'});
if(inp.focus)inp.focus();
}
return;
}
el=el.parentElement;
@@ -375,10 +497,10 @@ function viewWifi(){
try{var m=JSON.parse(res.body);}catch(e){m={};}
var html=navBar('wifi');
html+="<div class='wm-page-head'><h1>WiFi</h1><p class='wm-page-desc'>Pick a network below, confirm SSID and password, then connect.</p></div>";
html+="<div class='wm-card'><h2 class='wm-card-title'>Networks nearby</h2>";
html+="<div class='wm-card'>"+cardTitleHtml('scan','Networks nearby');
html+="<div id='wm-scan-results'></div>";
html+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-refresh-scan'>Scan again</button></div>";
html+="<div class='wm-card'><h2 class='wm-card-title'>Network details</h2>";
html+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-refresh-scan'>"+labelWithIcon('scan','Scan again')+"</button></div>";
html+="<div class='wm-card'>"+cardTitleHtml('wifi','Network details');
html+="<form id='wm-wifi-form'>";
html+=renderFieldList(m.wifiFields||[]);
var wf=m.wifiFields||[];
@@ -390,7 +512,7 @@ function viewWifi(){
}
html+=renderFieldList(m.staticFields||[]);
html+=renderFieldList(m.params||[]);
html+="<div class='wm-form-actions'><button type='submit' class='wm-btn wm-btn--primary wm-btn--block'>Save and connect</button></div></form>";
html+="<div class='wm-form-actions'><button type='submit' class='wm-btn wm-btn--primary wm-btn--block'>"+labelWithIcon('connect','Save and connect')+"</button></div></form>";
html+="<div id='wm-wifi-msg'></div></div>";
setView(html);
bindWifiViewEvents();
@@ -398,6 +520,10 @@ function viewWifi(){
api('/api/wifi/scan-status').then(function(r2){
try{var d=JSON.parse(r2.body);}catch(e){d={};}
var box=getScanResultsBox();
if(shouldAutoStartWifiScan(d)){
wifiRefresh();
return;
}
if(box)box.innerHTML=renderScanList(d);
if(d&&d.scanning){startWifiScanPolling(box);}
}).catch(function(){
@@ -412,18 +538,23 @@ function viewWifi(){
// --- WiFi connect polling ---
var _wmWifiConnectPollTimer=null;
var _wmWifiConnectPollToken=0;
var _wmWifiConnectPending=false;
function stopWifiConnectPolling(){
_wmWifiConnectPollToken++;
if(_wmWifiConnectPollTimer){
clearInterval(_wmWifiConnectPollTimer);
clearTimeout(_wmWifiConnectPollTimer);
_wmWifiConnectPollTimer=null;
}
}
function pollWifiConnectStatus(){
stopWifiConnectPolling();
_wmWifiConnectPollTimer=setInterval(function(){
var token=++_wmWifiConnectPollToken;
function poll(){
api('/api/wifi/connect-status').then(function(res){
if(token!==_wmWifiConnectPollToken)return;
var j={};
var msg=$('wm-wifi-msg');
try{j=JSON.parse(res.body);}catch(e){}
@@ -432,18 +563,36 @@ function pollWifiConnectStatus(){
}
if(j.state==='success'){
stopWifiConnectPolling();
_wmWifiConnectPending=false;
if(msg && j.stationIp){
msg.innerHTML="Connected to WiFi. Redirecting to <a href='"+esc(j.redirectUrl||("http://"+j.stationIp+"/"))+"'>"+esc(j.stationIp)+"</a>…";
}
showToast(j.message||'WiFi connected',false);
location.hash='#/';
if(j.redirectUrl){
setTimeout(function(){ window.location.href=j.redirectUrl; }, 1400);
}else{
location.hash='#/';
}
}else if(j.state==='failed'){
stopWifiConnectPolling();
_wmWifiConnectPending=false;
showToast(j.message||'WiFi connect failed',true);
}else{
_wmWifiConnectPollTimer=setTimeout(poll,700);
}
}).catch(function(){
if(token!==_wmWifiConnectPollToken)return;
stopWifiConnectPolling();
var msg=$('wm-wifi-msg');
if(msg)msg.innerHTML='Portal became unreachable while checking connection status.';
if(msg){
msg.innerHTML=_wmWifiConnectPending
? 'Portal became unreachable after saving. If the device joined your WiFi network, reconnect to it there.'
: 'Portal became unreachable while checking connection status.';
}
_wmWifiConnectPending=false;
});
},700);
}
poll();
}
window.portalWifiSave=function(ev){
@@ -458,6 +607,7 @@ window.portalWifiSave=function(ev){
msg.innerHTML=(j&&j.message)?esc(j.message):(res.ok?'Queued.':'Error');
}
if(res.status===202){
_wmWifiConnectPending=true;
pollWifiConnectStatus();
}
});
@@ -471,47 +621,41 @@ function viewInfo(){
try{var d=JSON.parse(res.body);}catch(e){d={};}
var html=navBar('info');
html+="<div class='wm-page-head'><h1>Device info</h1><p class='wm-page-desc'>Connection status, hardware, and actions.</p></div>";
function section(title,rows){
if(!rows||!rows.length)return'';
var h="<div class='wm-card'><h2 class='wm-card-title'>"+esc(title)+"</h2><dl class='wm-kv'>";
for(var i=0;i<rows.length;i++){
h+="<dt>"+esc(rows[i].label)+"</dt><dd>"+rows[i].value+"</dd>";
}
h+="</dl></div>";
return h;
}
var st=d.status;
if(st && typeof st==='object' && !Array.isArray(st)){
html+="<div class='wm-card'><h2 class='wm-card-title'>Status</h2><dl class='wm-kv'>";
html+="<dt>Connected</dt><dd>"+esc(String(st.connected))+"</dd>";
if(st.ssid!==undefined)html+="<dt>SSID</dt><dd>"+esc(st.ssid)+"</dd>";
html+="<div class='wm-card'>"+cardTitleHtml('status','Status');
if(st.summary){html+="<p class='wm-callout wm-callout--info wm-info-summary'>"+esc(st.summary)+"</p>";}
html+="<dl class='wm-kv'>";
html+="<dt>Connected</dt><dd>"+esc(st.connected?'Yes':'No')+"</dd>";
if(st.ssid!==undefined && st.ssid!=='')html+="<dt>SSID</dt><dd>"+esc(st.ssid)+"</dd>";
if(st.stationIp)html+="<dt>Station IP</dt><dd>"+esc(st.stationIp)+"</dd>";
if(st.apIp)html+="<dt>AP IP</dt><dd>"+esc(st.apIp)+"</dd>";
if(st.summary)html+="<dt>Summary</dt><dd>"+esc(st.summary)+"</dd>";
if(st.apIp)html+="<dt>Portal IP</dt><dd>"+esc(st.apIp)+"</dd>";
html+="</dl></div>";
} else {
html+=section('Status',d.status||[]);
html+=sectionCardHtml('status','Status','Connection summary and main addresses.',d.status||[]);
}
html+=section('Device',d.device||[]);
html+=section('WiFi',d.wifi||[]);
html+=section('About',d.about||[]);
html+=sectionCardHtml('device','Device','Runtime, memory, and hardware details.',d.device||[]);
html+=sectionCardHtml('diagnostics','WiFi details','Network diagnostics and interface metadata.',rowsWithoutKeys(d.wifi||[],['conx','stassid','staip','apip']));
html+=sectionCardHtml('about','About','Library, SDK, and build information.',d.about||[]);
if(d.extraSections){
var xi,zs,zrows,zi,it;
for(xi=0;xi<d.extraSections.length;xi++){
zs=d.extraSections[xi];
zrows=[];
if(zs.items){for(zi=0;zi<zs.items.length;zi++){it=zs.items[zi];zrows.push({label:it.label,value:esc(it.value)});}}
html+=section(zs.title,zrows);
html+=sectionCardHtml('info',zs.title||'Details','',zrows);
}
}
var act=d.actions||{};
html+="<div class='wm-card'><h2 class='wm-card-title'>Actions</h2><div class='wm-btn-group'>";
if(act.showRestart)html+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-restart-btn'>Restart device</button>";
if(act.showExitPortal)html+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-exit-btn'>Exit portal</button>";
if(act.showCloseCaptive)html+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-close-captive-btn'>Stop captive portal</button>";
if(act.showUpdate)html+="<a class='wm-btn wm-btn--secondary wm-btn--block' href='#/update'>Firmware update</a>";
if(act.showErase)html+="<button type='button' class='wm-btn wm-btn--danger wm-btn--block' id='wm-erase-btn'>Erase WiFi config</button>";
html+="</div></div>";
var actionsHtml='', hasActions=false;
if(act.showRestart){actionsHtml+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-restart-btn'>"+labelWithIcon('restart','Restart device')+"</button>";hasActions=true;}
if(act.showExitPortal){actionsHtml+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-exit-btn'>"+labelWithIcon('exit','Exit portal')+"</button>";hasActions=true;}
if(act.showCloseCaptive){actionsHtml+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-close-captive-btn'>"+labelWithIcon('close','Stop captive portal')+"</button>";hasActions=true;}
if(act.showUpdate){actionsHtml+="<a class='wm-btn wm-btn--secondary wm-btn--block' href='#/update'>"+labelWithIcon('update','Firmware update')+"</a>";hasActions=true;}
if(act.showErase){actionsHtml+="<button type='button' class='wm-btn wm-btn--danger wm-btn--block' id='wm-erase-btn'>"+labelWithIcon('erase','Erase WiFi config')+"</button>";hasActions=true;}
if(hasActions){
html+="<div class='wm-card'>"+cardTitleHtml('actions','Actions')+"<p class='wm-card-subtitle'>Maintenance and recovery actions for this device.</p><div class='wm-btn-group'>"+actionsHtml+"</div></div>";
}
setView(html);
});
}
@@ -548,7 +692,7 @@ function viewSetup(){
html+="<div class='wm-page-head'><h1>Setup</h1><p class='wm-page-desc'>Custom parameters stored on this device.</p></div>";
html+="<div class='wm-card'><form id='wm-param-form'>";
html+=renderFieldList(d.params||[]);
html+="<div class='wm-form-actions'><button type='submit' class='wm-btn wm-btn--primary wm-btn--block'>Save parameters</button></div>";
html+="<div class='wm-form-actions'><button type='submit' class='wm-btn wm-btn--primary wm-btn--block'>"+labelWithIcon('save','Save parameters')+"</button></div>";
html+="</form><div id='wm-param-msg'></div></div>";
setView(html);
});
@@ -579,7 +723,7 @@ function viewUpdate(){
html+="<div class='wm-card'><form id='wm-ota-form'>";
html+="<div class='wm-field'><label for='wm-ota-file'>Firmware file</label>";
html+="<input type='file' id='wm-ota-file' name='update' accept='.bin,.bin.gz'/></div>";
html+="<div class='wm-form-actions'><button type='submit' class='wm-btn wm-btn--primary wm-btn--block'>Upload firmware</button></div>";
html+="<div class='wm-form-actions'><button type='submit' class='wm-btn wm-btn--primary wm-btn--block'>"+labelWithIcon('upload','Upload firmware')+"</button></div>";
html+="</form>";
html+="<p class='wm-lead' style='margin-top:14px;margin-bottom:8px'>Choose the correct binary for this chip. Upload progress appears below.</p>";
html+="<pre id='wm-ota-log'></pre></div>";
+40 -14
View File
@@ -684,7 +684,9 @@ void WiFiManager::queuePortalConnect(const String& ssid, const String& pass) {
_cpConnectQueuedAt = millis();
_cpConnectStartedAt = 0;
_cpConnectDelayUntil = 0;
_cpConnectCloseAt = 0;
_cpConnectTimeoutMs = (_saveTimeout > 0) ? (unsigned long)_saveTimeout : _connectTimeout;
_cpConnectStationIp = "";
_cpConnectState = wm_cp_connect_state_t::queued;
_cpConnectMessage = ssid.length() ? F("WiFi connect queued") : F("Settings saved");
emitPortalEvent(WM_EVENT_PORTAL_CONNECT_QUEUED);
@@ -692,9 +694,24 @@ void WiFiManager::queuePortalConnect(const String& ssid, const String& pass) {
void WiFiManager::processPortalConnect() {
const unsigned long now = millis();
const unsigned long successHandoffDelayMs = 2500UL;
auto finalizePortalConnectSuccess = [&]() {
_cpConnectState = wm_cp_connect_state_t::success;
if (_savewificallback != NULL) {
#ifndef WM_NO_LOG
log(WiFiManagerLogLevel::Debug, kWiFiMgrLogSubsystem, F("[CB] _savewificallback calling"));
#endif
_savewificallback();
}
emitPortalEvent(WM_EVENT_PORTAL_CONNECT_SUCCESS);
if (_disableConfigPortal) {
shutdownConfigPortal();
}
};
auto failPortalConnect = [&](uint8_t status, const String& message) {
_cpConnectStatus = status;
_cpConnectMessage = message;
_cpConnectStationIp = "";
_cpConnectState = wm_cp_connect_state_t::failed;
updateConxResult(status);
if (_shouldBreakAfterConfig) {
@@ -711,14 +728,16 @@ void WiFiManager::processPortalConnect() {
switch (_cpConnectState) {
case wm_cp_connect_state_t::idle:
case wm_cp_connect_state_t::success:
case wm_cp_connect_state_t::failed:
return;
case wm_cp_connect_state_t::success:
return;
case wm_cp_connect_state_t::queued:
resetAsyncScan(false);
if (_cpConnectSsid.length() == 0) {
_cpConnectStatus = WL_IDLE_STATUS;
_cpConnectMessage = F("Settings saved");
_cpConnectStationIp = "";
_cpConnectState = wm_cp_connect_state_t::success;
emitPortalEvent(WM_EVENT_PORTAL_CONNECT_SUCCESS);
return;
@@ -746,6 +765,7 @@ void WiFiManager::processPortalConnect() {
}
_cpConnectStatus = WL_IDLE_STATUS;
_cpConnectMessage = F("Settings saved");
_cpConnectStationIp = "";
_cpConnectState = wm_cp_connect_state_t::success;
if (_savewificallback != NULL) {
#ifndef WM_NO_LOG
@@ -771,17 +791,15 @@ void WiFiManager::processPortalConnect() {
_cpConnectStatus = status;
if (status == WL_CONNECTED) {
updateConxResult(status);
_cpConnectMessage = F("WiFi connected");
_cpConnectState = wm_cp_connect_state_t::success;
if (_savewificallback != NULL) {
#ifndef WM_NO_LOG
log(WiFiManagerLogLevel::Debug, kWiFiMgrLogSubsystem, F("[CB] _savewificallback calling"));
#endif
_savewificallback();
}
emitPortalEvent(WM_EVENT_PORTAL_CONNECT_SUCCESS);
if (_disableConfigPortal) {
shutdownConfigPortal();
_cpConnectStationIp = WiFi.localIP().toString();
_cpConnectMessage = _cpConnectStationIp.length() > 0
? String(F("WiFi connected. Redirecting to ")) + _cpConnectStationIp
: String(F("WiFi connected"));
if (_disableConfigPortal && (configPortalActive || webPortalActive)) {
_cpConnectCloseAt = now + successHandoffDelayMs;
_cpConnectState = wm_cp_connect_state_t::success_waiting_close;
} else {
finalizePortalConnectSuccess();
}
return;
}
@@ -794,6 +812,11 @@ void WiFiManager::processPortalConnect() {
}
return;
}
case wm_cp_connect_state_t::success_waiting_close:
if (now >= _cpConnectCloseAt) {
finalizePortalConnectSuccess();
}
return;
}
}
@@ -2233,6 +2256,7 @@ WiFiManager::wm_configportal_connect_state_t WiFiManager::getConfigPortalConnect
case wm_cp_connect_state_t::starting:
case wm_cp_connect_state_t::waiting:
return WM_CP_CONNECT_WAITING;
case wm_cp_connect_state_t::success_waiting_close:
case wm_cp_connect_state_t::success:
return WM_CP_CONNECT_SUCCESS;
case wm_cp_connect_state_t::failed:
@@ -2245,11 +2269,13 @@ WiFiManager::wm_configportal_connect_state_t WiFiManager::getConfigPortalConnect
bool WiFiManager::isConfigPortalConnectPending() const {
return _cpConnectState == wm_cp_connect_state_t::queued || _cpConnectState == wm_cp_connect_state_t::delaying
|| _cpConnectState == wm_cp_connect_state_t::starting || _cpConnectState == wm_cp_connect_state_t::waiting;
|| _cpConnectState == wm_cp_connect_state_t::starting || _cpConnectState == wm_cp_connect_state_t::waiting
|| _cpConnectState == wm_cp_connect_state_t::success_waiting_close;
}
bool WiFiManager::didConfigPortalConnectSucceed() const {
return _cpConnectState == wm_cp_connect_state_t::success;
return _cpConnectState == wm_cp_connect_state_t::success
|| _cpConnectState == wm_cp_connect_state_t::success_waiting_close;
}
bool WiFiManager::didConfigPortalConnectFail() const {
+32 -11
View File
@@ -129,6 +129,15 @@ struct PortalShellRenderBundle {
appendJsDescriptor{} {}
};
// Custom HTML attribute strings (e.g. from DeviceFramework generateCustomHTML) may include
// type='password' — the JSON field descriptor must echo that or the SPA renders type=text.
bool portalCustomAttrsIndicatePassword(const String& customAttrs) {
if (customAttrs.length() == 0) {
return false;
}
return customAttrs.indexOf("type='password'") >= 0 || customAttrs.indexOf("type=\"password\"") >= 0;
}
} // namespace
WiFiManagerHandlers::WiFiManagerHandlers(WiFiManager* wm) : _wm(wm) {}
@@ -308,31 +317,31 @@ void WiFiManagerHandlers::appendPortalJsonCustomParams(String& json, bool& first
first = false;
if (p->getID() != nullptr) {
String pid = String(p->getID());
const String customAttrs = String(p->getCustomHTML());
json += F("{\"kind\":\"field\",\"name\":\"");
jsonAppendEscaped(json, pid);
json += F("\",\"id\":\"");
jsonAppendEscaped(json, pid);
json += F("\",\"label\":\"");
jsonAppendEscaped(json, String(p->getLabel()));
json += F("\",\"type\":\"text\",\"maxlength\":");
json += F("\",\"type\":\"");
json += portalCustomAttrsIndicatePassword(customAttrs) ? F("password") : F("text");
json += F("\",\"maxlength\":");
json += String(p->getValueLength());
json += F(",\"value\":\"");
jsonAppendEscaped(json, String(p->getValue()));
json += F("\",\"labelPlacement\":");
json += String(p->getLabelPlacement());
{
String c = p->getCustomHTML();
if (c.length() > 0) {
json += F(",\"customAttrs\":\"");
jsonAppendEscaped(json, c);
json += F("\"");
}
if (customAttrs.length() > 0) {
json += F(",\"customAttrs\":\"");
jsonAppendEscaped(json, customAttrs);
json += F("\"");
}
json += F("}");
} else {
json += F("{\"kind\":\"html\",\"html\":\"");
jsonAppendEscaped(json, String(p->getCustomHTML()));
json += F("}");
json += F("\"}");
}
}
}
@@ -349,7 +358,7 @@ void WiFiManagerHandlers::appendJsonKvItem(String& json, bool& first, const char
jsonAppendEscaped(json, label);
json += F("\",\"value\":\"");
jsonAppendEscaped(json, value);
json += F("}");
json += F("\"}");
}
void WiFiManagerHandlers::appendInfoSectionFromIds(String& json, const char* const* ids, size_t count, bool& first) {
@@ -1249,7 +1258,19 @@ String WiFiManagerHandlers::buildApiWifiConnectStatusJson() {
jsonAppendEscaped(json, _wm->getConfigPortalConnectMessage());
json += F("\",\"wifiStatus\":\"");
jsonAppendEscaped(json, _wm->getWLStatusString(_wm->getConfigPortalConnectStatus()));
json += F("\"}");
json += F("\"");
if (_wm->_cpConnectStationIp.length() > 0) {
json += F(",\"stationIp\":\"");
jsonAppendEscaped(json, _wm->_cpConnectStationIp);
json += F("\",\"redirectUrl\":\"http://");
jsonAppendEscaped(json, _wm->_cpConnectStationIp);
if (_wm->_httpPort != 80) {
json += F(":");
json += String(_wm->_httpPort);
}
json += F("/\"");
}
json += F("}");
return json;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "WiFiManager",
"version": "2.0.18",
"version": "2.0.19",
"keywords": [
"wifi",
"wi-fi",
+2
View File
@@ -11,6 +11,8 @@ TestCase tests[] = {
// JSON API shape (buildApiInfoJson is large — run with fresh heap, before stress tests)
TEST_ENTRY(test_captive_redirect_host_rule),
TEST_ENTRY(test_api_wifi_meta_json_shape),
TEST_ENTRY(test_api_wifi_meta_password_field_type),
TEST_ENTRY(test_api_wifi_connect_status_success_redirect),
TEST_ENTRY(test_api_info_json_shape),
TEST_ENTRY(test_api_params_json_shape),
TEST_ENTRY(test_api_status_json_shape),
+2
View File
@@ -113,6 +113,8 @@ void test_shell_template_renders_dynamic_styles_with_percent_values();
// API JSON + captive helper tests
void test_captive_redirect_host_rule();
void test_api_wifi_meta_json_shape();
void test_api_wifi_meta_password_field_type();
void test_api_wifi_connect_status_success_redirect();
void test_api_info_json_shape();
void test_api_params_json_shape();
void test_api_status_json_shape();
@@ -36,10 +36,48 @@ void test_api_wifi_meta_json_shape() {
j = handlers.buildApiWifiMetaJson();
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"kind\":\"field\"")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"kind\":\"html\"")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"html\":\"<div class='wm-callout wm-callout--info'><p>GPS settings...</p></div>\"}")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"wifiFields\":[{\"id\":\"s\"")));
Serial.println("[TEST] WiFi meta JSON shape test completed successfully");
}
void test_api_wifi_meta_password_field_type() {
Serial.println("[TEST] Testing /api/wifi/meta password field type from custom attrs...");
WiFiManager wm;
WiFiManagerHandlers handlers(&wm);
WiFiManagerParameter pwd("mqttpass", "MQTT password", "secret", 24, " type='password' autocorrect='off'");
wm.portalAddParameter(&pwd);
String j = handlers.buildApiWifiMetaJson();
const char* p = j.c_str();
const char* chunk = strstr(p, "\"id\":\"mqttpass\"");
TEST_ASSERT_NOT_NULL_MESSAGE(chunk, "mqttpass param missing from meta JSON");
TEST_ASSERT_NOT_NULL_MESSAGE(strstr(chunk, "\"type\":\"password\""),
"customAttrs type=password must map to JSON type password");
Serial.println("[TEST] WiFi meta password field type test completed successfully");
}
void test_api_wifi_connect_status_success_redirect() {
Serial.println("[TEST] Testing /api/wifi/connect-status success redirect payload...");
WiFiManager wm;
WiFiManagerHandlers handlers(&wm);
#ifdef UNIT_TEST
wm.wmTestSetPortalConnectSuccess("WiFi connected. Redirecting to 192.168.1.42", "192.168.1.42");
#endif
String j = handlers.buildApiWifiConnectStatusJson();
const char* p = j.c_str();
TEST_ASSERT_NOT_NULL(strstr(p, "\"state\":\"success\""));
TEST_ASSERT_NOT_NULL(strstr(p, "\"stationIp\":\"192.168.1.42\""));
TEST_ASSERT_NOT_NULL(strstr(p, "\"redirectUrl\":\"http://192.168.1.42/\""));
Serial.println("[TEST] WiFi connect-status success redirect payload test completed successfully");
}
void test_api_info_json_shape() {
Serial.println("[TEST] Testing /api/info JSON shape...");
@@ -67,6 +105,8 @@ void test_api_info_json_shape() {
TEST_ASSERT_NOT_NULL(strstr(p, "\"title\":\"Battery\""));
TEST_ASSERT_NOT_NULL(strstr(p, "\"label\":\"State of charge\""));
TEST_ASSERT_NOT_NULL(strstr(p, "\"showUpdate\":"));
TEST_ASSERT_NOT_NULL(strstr(p, "\"key\":\"aboutver\",\"label\":\"WiFiManager\",\"value\":\""));
TEST_ASSERT_NOT_NULL(strstr(p, "\"},{\"key\":\"aboutarduinover\""));
Serial.println("[TEST] API info JSON shape test completed successfully");
}