Portal customization API v2, SPA hardening, and test cleanup

- Group portal state into brand/pages/actions/layout structs; portal* API
- Bootstrap JSON: nested brand/context/pages/actions/layout; param kind
- Handlers: PROGMEM default CSS when no override/append; dynamic CSS path
- PortalAppJS: portal timeout countdown; safe WiFi scan DOM; serialized
  scan-status polling; fetch error handling; wm:view-changed on redirects
- CSS: scan list icon alignment; wm-status for timeout
- Tests: remove low-value/no-op tests; trim fragile JS substring checks
This commit is contained in:
2026-05-04 22:03:43 +10:00
parent 93711d981d
commit 7735d7f0cb
22 changed files with 1039 additions and 326 deletions
+102 -2
View File
@@ -30,9 +30,10 @@ This fork currently includes the following architectural improvements:
- **Captive portal redirect handling** retained while removing duplicate legacy UI architecture.
- **Data-first JSON generation** for portal APIs, including info/device/about data, instead of HTML-to-JSON parsing.
- **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.
- **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 only `%PAGE_TITLE%`, `%STYLES%`, `%BOOTSTRAP_JSON%`, and `%PORTAL_APP_JS%` — filled in `WiFiManagerHandlers` from WiFiManager state and embedded assets (not from a server-wide template registry).
- **Customization via WiFiManager APIs** (`setTitle`, `setShowInfo`, `setParametersEmbeddedInWifi`, bootstrap-facing flags, etc.), not by exposing placeholder-registry mutation to consumers.
- **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:
- shell rendering (handlers + SPA bootstrap)
- JSON API responses
@@ -40,6 +41,105 @@ This fork currently includes the following architectural improvements:
- OTA handling
- Updated tests focused on the **shell contract**, **bootstrap payloads**, and **API JSON shapes** rather than removed legacy portal pages.
## Portal Customization Boundary
Stable, supported portal customization is intentionally scoped:
- `portalSetBrand*` for title, intro text, and logo SVG, plus `portalSetContextIdentityText(...)` for the user-facing runtime identity string on the home view.
- `portalSetPage*`, `portalSetAction*`, `portalSetLayout*`, and `portalSetBehavior*` for built-in portal capabilities and runtime behavior.
- `portalAddParameter(...)` for first-class custom parameters, including raw HTML blocks inside parameter-rendering surfaces (`#/wifi` or `#/setup`).
- `portalAddInfoSection(...)` and `portalAddHomeCard(...)` for structured extra content rendered by the built-in SPA.
- `portalAppendCss(...)`, `portalOverrideCss(...)`, and `portalAppendJs(...)` for light theming and enhancement hooks.
Not part of the stable API:
- arbitrary HTML injection into home/info/nav/shell
- replacing built-in SPA routing or action flow
- depending on undocumented DOM IDs or route internals
- treating `include/templates/*` as a supported consumer override surface
Appended JS should enhance rather than replace the built-in SPA. The documented hook contract is:
- `wm:ready` with `detail.boot`
- `wm:view-changed` with `detail.route`
If a consumer needs custom live widgets, new primary navigation concepts, or new backend-to-frontend workflows, that is considered **fork territory** rather than portal customization.
## Portal Customization Example
```cpp
WiFiManager wm;
wm.portalSetBrandTitle("Solar Battery Monitor Setup");
wm.portalSetContextIdentityText("Solar Battery Monitor");
wm.portalSetBrandHomeIntro(
"Connect your monitor to WiFi, then review battery and inverter settings."
);
wm.portalSetBrandLogoSvg(
"<svg viewBox='0 0 24 24' aria-hidden='true'>"
"<path d='M12 2L4 12h5l-1 10 8-10h-5z'></path>"
"</svg>"
);
wm.portalSetPageInfoVisible(true);
wm.portalSetPageUpdateVisible(false);
wm.portalSetActionEraseVisible(false);
wm.portalSetActionRestartVisible(true);
wm.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage);
wm.portalSetBehaviorCaptivePortalEnabled(true);
wm.portalSetBehaviorConnectOnSave(true);
wm.portalSetBehaviorExitAllowed(true);
wm.portalSetFieldPasswordPlaceholderMode(PortalPasswordPlaceholderMode::Masked);
wm.portalSetFieldStaticIpVisibility(PortalFieldVisibility::Auto);
wm.portalSetFieldStaticDnsVisibility(PortalFieldVisibility::Auto);
WiFiManagerParameter mqttHost(
"mqtt_host",
"MQTT host",
"broker.local",
64,
"placeholder='broker.local'"
);
wm.portalAddParameter(&mqttHost);
// Raw HTML remains first-class for custom parameters, but only inside the
// parameter-rendering surfaces (#/wifi or #/setup), not arbitrary portal regions.
WiFiManagerParameter gpsHelp(
"<div class='wm-callout wm-callout--info'>"
"<p>GPS options below apply only when a GPS module is connected.</p>"
"</div>"
);
wm.portalAddParameter(&gpsHelp);
PortalInfoSection battery;
battery.id = "battery";
battery.title = "Battery";
battery.items.push_back({"soc", "State of charge", "84%"});
battery.items.push_back({"voltage", "Voltage", "13.2V"});
wm.portalAddInfoSection(battery);
PortalHomeCard solar;
solar.id = "solar";
solar.title = "Solar summary";
solar.kind = PortalHomeCardKind::KeyValue;
solar.items.push_back({"pv", "PV input", "420W"});
solar.items.push_back({"load", "Load", "180W"});
wm.portalAddHomeCard(solar);
wm.portalAppendCss(
".wm-brand-logo svg{width:40px;height:40px;display:block;}"
".wm-hero-intro{max-width:42ch;}"
);
wm.portalAppendJs(
"document.addEventListener('wm:ready', function(e){"
" console.log('Portal booted', e.detail.boot);"
"});"
);
```
## Dependencies
This fork depends on **DFTE** ([Device Framework Template Engine](https://github.com/alexhopeoconnor/DFTE)) and **ESP32Async/ESPAsyncWebServer**.
+117 -34
View File
@@ -200,6 +200,74 @@ class WiFiManagerHandlers;
class WiFiManagerDfteLogger;
class WiFiManagerLogSink;
// ---------------------------------------------------------------------------
// Portal customization (v2 contract: nested JSON; all entry points use portal* API)
// ---------------------------------------------------------------------------
enum class PortalParamsLocation : uint8_t { WiFiPage = 0, SetupPage = 1 };
enum class PortalPasswordPlaceholderMode : uint8_t { Hidden = 0, Masked = 1, Actual = 2 };
enum class PortalFieldVisibility : uint8_t { Hidden = 0, Auto = 1, Always = 2 };
enum class PortalHomeCardKind : uint8_t { Text = 0, Callout = 1, KeyValue = 2 };
struct PortalKeyValueItem {
String key;
String label;
String value;
};
struct PortalInfoSection {
String id;
String title;
std::vector<PortalKeyValueItem> items;
};
struct PortalHomeCard {
String id;
String title;
PortalHomeCardKind kind = PortalHomeCardKind::KeyValue;
String text;
std::vector<PortalKeyValueItem> items;
};
struct PortalBrandState {
String title = "WiFiManager";
String identityTextOverride;
String homeIntro;
String logoSvg;
};
struct PortalPageState {
bool infoVisible = true;
bool updateVisible = true;
bool setupVisible = true;
};
struct PortalActionState {
bool eraseVisible = true;
bool restartVisible = true;
bool exitVisible = true;
bool closeCaptiveVisible = true;
bool backVisible = false;
};
struct PortalLayoutState {
/** When true, custom parameters render on the Wi-Fi page; when false, only on #/setup. */
bool paramsOnWifiPage = true;
};
struct PortalAssetState {
String appendedCss;
String overriddenCss;
String appendedJs;
};
struct PortalStructuredExtrasState {
std::vector<PortalInfoSection> infoSections;
std::vector<PortalHomeCard> homeCards;
};
class WiFiManager
{
public:
@@ -322,7 +390,7 @@ class WiFiManager
bool erase(bool opt);
//adds a custom parameter, returns false on failure
bool addParameter(WiFiManagerParameter *p);
bool portalAddParameter(WiFiManagerParameter *p);
//returns the list of Parameters
WiFiManagerParameter** getParameters();
@@ -421,12 +489,6 @@ class WiFiManager
//if true, always show static dns, esle only show if set via setSTAStaticIPConfig
void setShowDnsFields(boolean alwaysShow);
// toggle showing the saved wifi password in wifi form, could be a security issue.
void setShowPassword(boolean show);
//if false, disable captive portal redirection
void setCaptivePortalEnable(boolean enabled);
//if false, timeout captive portal even if a STA client connected to softAP (false), suggest disabling if captiveportal is open
void setAPClientCheck(boolean enabled);
@@ -449,15 +511,6 @@ class WiFiManager
bool setHostname(const char * hostname);
bool setHostname(String hostname);
// show erase wifi onfig button on info page, true
void setShowInfoErase(boolean enabled);
// show OTA upload button on info page
void setShowInfoUpdate(boolean enabled);
// show Info nav / info view (portal SPA); default true
void setShowInfo(boolean enabled);
// set ap channel
void setWiFiAPChannel(int32_t channel);
@@ -467,18 +520,46 @@ class WiFiManager
// clean connect, always disconnect before connecting
void setCleanConnect(bool enable); // default false
// set the webapp title, default WiFiManager
void setTitle(String title);
// ---- Portal (all customization entry points use the portal* prefix) ----
void portalSetBrandTitle(const String& title);
void portalSetContextIdentityText(const String& identityText);
void portalSetBrandHomeIntro(const String& text);
void portalSetBrandLogoSvg(const String& svgMarkup);
/**
* Where custom parameters (`WiFiManagerParameter`) appear in the portal SPA (one placement only).
*
* - true (default): parameters render on the WiFi route (`#/wifi`) together with SSID/password.
* - false: parameters render only on the Setup route (`#/setup`); they are not duplicated on WiFi.
*
* Bootstrap exposes the same flag as `features.paramsInWifi`.
*/
void setParametersEmbeddedInWifi(boolean embedded);
void portalSetPageInfoVisible(bool visible);
void portalSetPageUpdateVisible(bool visible);
void portalSetPageSetupVisible(bool visible);
void portalSetActionEraseVisible(bool visible);
void portalSetActionRestartVisible(bool visible);
void portalSetActionExitVisible(bool visible);
void portalSetActionCloseCaptiveVisible(bool visible);
void portalSetActionBackVisible(bool visible);
void portalSetLayoutParamsLocation(PortalParamsLocation location);
void portalSetBehaviorCaptivePortalEnabled(bool enabled);
void portalSetBehaviorConnectOnSave(bool enabled);
void portalSetBehaviorExitAllowed(bool allowed);
void portalSetBehaviorConnectTimeoutSeconds(unsigned long seconds);
void portalSetBehaviorPortalTimeoutSeconds(unsigned long seconds);
void portalSetBehaviorAutoReconnect(bool enabled);
void portalSetBehaviorApClientCheck(bool enabled);
void portalSetBehaviorWebClientCheck(bool enabled);
void portalSetFieldPasswordPlaceholderMode(PortalPasswordPlaceholderMode mode);
void portalSetFieldStaticIpVisibility(PortalFieldVisibility visibility);
void portalSetFieldStaticDnsVisibility(PortalFieldVisibility visibility);
void portalClearParameters();
void portalAddInfoSection(const PortalInfoSection& section);
void portalClearInfoSections();
void portalAddHomeCard(const PortalHomeCard& card);
void portalClearHomeCards();
void portalAppendCss(const String& css);
void portalOverrideCss(const String& css);
void portalAppendJs(const String& js);
// get last connection result, including autoconnect and portal credential-save attempts
uint8_t getLastConxResult();
@@ -706,7 +787,7 @@ class WiFiManager
int _staShowStaticFields = 0; // ternary 1=always show static ip fields, 0=only if set, -1=never(cannot change ips via web!)
int _staShowDns = 0; // ternary 1=always show dns, 0=only if set, -1=never(cannot change dns via web!)
boolean _removeDuplicateAPs = true; // remove dup aps from wifiscan
boolean _showPassword = false; // show or hide saved password on wifi form, might be a security issue!
PortalPasswordPlaceholderMode _portalPasswordPlaceholderMode = PortalPasswordPlaceholderMode::Masked;
boolean _shouldBreakAfterConfig = false; // stop configportal on save failure
boolean _enableCaptivePortal = true; // enable captive portal redirection
boolean _userpersistent = true; // users preffered persistence to restore
@@ -714,15 +795,17 @@ class WiFiManager
boolean _apClientCheck = false; // keep cp alive if ap have station
boolean _webClientCheck = true; // keep cp alive if web have client
boolean _scanDispOptions = false; // show percentage in scans not icons
boolean _paramsInWifi = true; // if false, custom params only on #/setup (see setParametersEmbeddedInWifi)
boolean _showInfo = true; // show Info in portal SPA
boolean _showInfoErase = true; // info page erase button
boolean _showInfoUpdate = true; // info page update button
boolean _showBack = false; // show back button
boolean _enableConfigPortal = true; // FOR autoconnect - start config portal if autoconnect failed
boolean _disableConfigPortal = true; // FOR autoconnect - stop config portal if cp wifi save
String _hostname = ""; // hostname for esp8266 for dhcp, and or MDNS
String _title = "WiFiManager"; // app title - default WiFiManager
// Grouped portal presentation / customization (see portal* setters; JSON in v2 bootstrap)
PortalBrandState _portalBrand;
PortalPageState _portalPages;
PortalActionState _portalActions;
PortalLayoutState _portalLayout;
PortalAssetState _portalAssets;
PortalStructuredExtrasState _portalStructured;
// internal options
@@ -20,8 +20,7 @@
#define WM_TEMPLATE_REGISTRY_CAPACITY 16
#endif
// Portal UI customization is driven by WiFiManager state and helpers such as setTitle(), setShowInfo(),
// setParametersEmbeddedInWifi(), setShowInfoUpdate(), etc., surfaced through bootstrap/API JSON — not by mutating template
// Portal UI customization is driven by WiFiManager `portal*` APIs and JSON (/api/...) — not by mutating template
// placeholder registries.
// -----------------------------------------------------------------------------------------------
@@ -106,11 +105,14 @@ class WiFiManagerHandlers {
void appendPortalJsonStaticFields(String& json, bool& first);
void appendPortalJsonCustomParams(String& json, bool& first);
String composePortalStylesheet() const;
void appendPortalExtraInfoSectionsJson(String& json, bool& first);
void appendPortalExtraHomeCardsJson(String& json, bool& first);
void appendJsonKvItem(String& json, bool& first, const char* key, const String& label, const String& value);
void appendOneInfoItemForId(String& json, bool& first, const char* id);
void appendInfoSectionFromIds(String& json, const char* const* ids, size_t count, bool& first);
void appendPortalUiFeatureFlagsJson(String& json);
void appendApiInfoActionsJson(String& json);
static void sendApiJson(AsyncWebServerRequest *request, int code, const String& json);
+9 -3
View File
@@ -70,6 +70,9 @@ const char CSS_STYLE[] PROGMEM = "<style>"
".wm-hero{margin-bottom:20px}"
".wm-hero-title{font-size:1.65rem;line-height:1.2;margin-bottom:.25em}"
".wm-hero-sub{font-size:1rem;color:var(--wm-muted);font-weight:500;margin:0}"
".wm-brand-logo{margin-bottom:12px;line-height:0}"
".wm-brand-logo svg{display:block;max-width:160px;height:auto}"
".wm-hero-intro{font-size:.95rem;color:var(--wm-muted);margin:.5em 0 0;line-height:1.45;max-width:42ch}"
".wm-page-head{margin-bottom:16px}"
".wm-page-head h1{font-size:1.45rem}"
".wm-page-desc{font-size:.95rem;color:var(--wm-muted);margin:.35em 0 0}"
@@ -85,7 +88,7 @@ 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;font-size:.9rem}"
".wm-status{margin:0 0 10px;font-size:.9rem;font-weight:600;color:var(--wm-brand)}"
/* Form fields */
".wm-field{margin-bottom:14px;text-align:left}"
".wm-field:last-child{margin-bottom:0}"
@@ -130,10 +133,13 @@ const char CSS_STYLE[] PROGMEM = "<style>"
".wm-scan-row:hover{background:rgba(14,165,233,.1)}"
"body.invert .wm-scan-row:hover{background:rgba(56,189,248,.12)}"
".wm-scan-ssid{flex:1;min-width:0;font-weight:600;font-size:.95rem;text-align:left;word-break:break-word}"
".wm-scan-signal{display:flex;align-items:center;gap:6px;flex-shrink:0;font-size:.8rem;color:var(--wm-muted);font-weight:600}"
".wm-scan-meta{display:flex;align-items:center;justify-content:flex-end;gap:6px;flex-shrink:0;min-width:78px}"
".wm-scan-signal{display:block;flex-shrink:0;font-size:.8rem;color:var(--wm-muted);font-weight:600;min-width:38px;text-align:right}"
".wm-scan-lock,.wm-scan-bars{display:block;flex:0 0 16px}"
// quality icons
".q{height:16px;width:16px;margin:0;padding:0;flex-shrink:0}.q.q-0:after{background-position-x:0}.q.q-1:after{background-position-x:-16px}.q.q-2:after{background-position-x:-32px}.q.q-3:after{background-position-x:-48px}.q.q-4:after{background-position-x:-64px}.q.l:before{background-position-x:-80px;padding-right:5px}.ql .q{float:left}.q:after,.q:before{content:'';width:16px;height:16px;display:inline-block;background-repeat:no-repeat;background-position:16px 0;"
".q{position:relative;height:16px;width:16px;margin:0;padding:0;flex-shrink:0}.q.q-0:after{background-position-x:0}.q.q-1:after{background-position-x:-16px}.q.q-2:after{background-position-x:-32px}.q.q-3:after{background-position-x:-48px}.q.q-4:after{background-position-x:-64px}.q.l:before{background-position-x:-80px}.ql .q{float:left}.q:after,.q:before{content:'';position:absolute;left:0;top:0;width:16px;height:16px;display:block;background-repeat:no-repeat;background-position:16px 0;"
"background-image:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAAAQCAMAAADeZIrLAAAAJFBMVEX///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADHJj5lAAAAC3RSTlMAIjN3iJmqu8zd7vF8pzcAAABsSURBVHja7Y1BCsAwCASNSVo3/v+/BUEiXnIoXkoX5jAQMxTHzK9cVSnvDxwD8bFx8PhZ9q8FmghXBhqA1faxk92PsxvRc2CCCFdhQCbRkLoAQ3q/wWUBqG35ZxtVzW4Ed6LngPyBU2CobdIDQ5oPWI5nCUwAAAAASUVORK5CYII=');}"
".wm-scan-lock:after,.wm-scan-bars:before{content:none}"
"@media (-webkit-min-device-pixel-ratio: 2),(min-resolution: 192dpi){.q:before,.q:after {"
"background-image:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALwAAAAgCAMAAACfM+KhAAAALVBMVEX///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAOrOgAAAADnRSTlMAESIzRGZ3iJmqu8zd7gKjCLQAAACmSURBVHgB7dDBCoMwEEXRmKlVY3L//3NLhyzqIqSUggy8uxnhCR5Mo8xLt+14aZ7wwgsvvPA/ofv9+44334UXXngvb6XsFhO/VoC2RsSv9J7x8BnYLW+AjT56ud/uePMdb7IP8Bsc/e7h8Cfk912ghsNXWPpDC4hvN+D1560A1QPORyh84VKLjjdvfPFm++i9EWq0348XXnjhhT+4dIbCW+WjZim9AKk4UZMnnCEuAAAAAElFTkSuQmCC');"
"background-size: 95px 16px;}}"
+192 -40
View File
@@ -24,6 +24,7 @@ function esc(t){if(!t)return'';return String(t).replace(/[&<>"']/g,function(c){r
// --- Toast / dialog ---
var _wmToastTimer=null;
var _wmPortalTimeoutTimer=null;
function showToast(msg,isErr){
var el=$('wm-toast');
if(!el)return;
@@ -72,9 +73,17 @@ function bindFormSubmitHandlers(){
}
// --- View shell ---
function stopPortalTimeoutCountdown(){
if(_wmPortalTimeoutTimer){
clearInterval(_wmPortalTimeoutTimer);
_wmPortalTimeoutTimer=null;
}
}
function setView(html){
var a=$('app');
if(!a)return;
stopPortalTimeoutCountdown();
stopWifiScanPolling();
stopWifiConnectPolling();
a.innerHTML="<div class='wm-layout'>"+html+"</div>";
@@ -82,10 +91,35 @@ function setView(html){
bindFormSubmitHandlers();
}
function formatDurationSeconds(totalSeconds){
totalSeconds=Math.max(0,Math.floor(totalSeconds||0));
var hours=Math.floor(totalSeconds/3600);
var minutes=Math.floor((totalSeconds%3600)/60);
var seconds=totalSeconds%60;
if(hours>0){
return hours+":"+String(minutes).padStart(2,'0')+":"+String(seconds).padStart(2,'0');
}
return minutes+":"+String(seconds).padStart(2,'0');
}
function startPortalTimeoutCountdown(initialSeconds){
var el=$('wm-portal-timeout');
var remaining=Math.max(0,Math.floor(initialSeconds||0));
if(!el||remaining<=0)return;
function render(){
if(!el)return;
el.textContent='Captive portal timeout: '+formatDurationSeconds(remaining);
}
render();
_wmPortalTimeoutTimer=setInterval(function(){
remaining=Math.max(0,remaining-1);
render();
if(remaining<=0)stopPortalTimeoutCountdown();
},1000);
}
// --- Field render (WiFi / params meta) ---
function renderMetaField(f){
if(!f)return'';
if(f.html)return f.html;
function renderStandardField(f){
var id=f.id||f.name||'';
var nm=f.name||f.id||'';
var domId=(id==='s')?'wm-s':(id==='p')?'wm-p':('wm-f-'+id);
@@ -102,6 +136,13 @@ function renderMetaField(f){
return h;
}
function renderMetaField(f){
if(!f)return'';
if(f.kind==='html')return f.html||'';
if(f.kind==='field' || !f.kind)return renderStandardField(f);
return '';
}
function renderFieldList(arr){
if(!arr||!arr.length)return'';
var h='',i;
@@ -111,43 +152,78 @@ function renderFieldList(arr){
function navBar(active){
active=active||'home';
var f=boot.features||{};
var pages=boot.pages||{};
var layout=boot.layout||{};
var actions=boot.actions||{};
var pinWifi=(layout.paramsLocation||'wifi')==='wifi';
var ps=pages.setup||{};
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(!f.paramsInWifi){h+="<a class='wm-nav-link"+(active==='setup'?' wm-nav-link--active':'')+"' href='#/setup'>Setup</a>";}
if(f.showInfo!==false){h+="<a class='wm-nav-link"+(active==='info'?' wm-nav-link--active':'')+"' href='#/info'>Info</a>";}
if(f.showUpdate){h+="<a class='wm-nav-link"+(active==='update'?' wm-nav-link--active':'')+"' href='#/update'>Update</a>";}
if(boot.showBack){h+="<a href='#' class='wm-nav-link' id='wm-nav-back'>Back</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+="</nav>";
return h;
}
function deviceActionsHtml(){
var f=boot.features||{};
var actions=boot.actions||{};
var h='', ok=false;
if(f.showRestart){h+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-restart-btn'>Restart device</button>";ok=true;}
if(f.showExitPortal){h+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-exit-btn'>Exit portal</button>";ok=true;}
if(f.showCloseCaptive){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'>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(!ok)return'';
return "<div class='wm-card'><h2 class='wm-card-title'>Device</h2><div class='wm-btn-group'>"+h+"</div></div>";
}
function renderExtraHomeCards(cards){
if(!cards||!cards.length)return'';
var out='',i,c,j,k,cls;
for(i=0;i<cards.length;i++){
c=cards[i];
if(!c)continue;
out+="<div class='wm-card'>";
if(c.title){out+="<h2 class='wm-card-title'>"+esc(c.title)+"</h2>";}
if(c.kind==='text'||c.kind==='callout'){
cls=c.kind==='callout'?'wm-callout wm-callout--info':'wm-lead';
out+="<p class='"+cls+"'>"+esc(c.text||'')+"</p>";
}else{
out+="<dl class='wm-kv'>";
if(c.items){for(j=0;j<c.items.length;j++){k=c.items[j];out+="<dt>"+esc(k.label)+"</dt><dd>"+esc(k.value)+"</dd>";}}
out+="</dl>";
}
out+="</div>";
}
return out;
}
function viewHome(){
var sub=esc(boot.subtitle||'');
var t=esc(boot.title||'WiFiManager');
var brand=boot.brand||{};
var ctx=boot.context||{};
var t=esc(brand.title||'WiFiManager');
var ident=ctx.identityText?esc(ctx.identityText):'';
var summary=ctx.statusSummary||'';
var timeoutSeconds=ctx.portalTimeoutSecondsRemaining||0;
var html=navBar('home');
html+="<header class='wm-hero'><h1 class='wm-hero-title'>"+t+"</h1>";
if(sub)html+="<p class='wm-hero-sub'>"+sub+"</p>";
html+="<header class='wm-hero'>";
if(brand.logoSvg){html+="<div class='wm-brand-logo'>"+brand.logoSvg+"</div>";}
html+="<h1 class='wm-hero-title'>"+t+"</h1>";
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>";
if(boot.initialStatus){
html+="<p class='wm-home-summary'>"+esc(boot.initialStatus)+"</p>";
}
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>";
html+="</div>";
html+=renderExtraHomeCards(boot.extraHomeCards||[]);
html+=deviceActionsHtml();
setView(html);
if(timeoutSeconds>0)startPortalTimeoutCountdown(timeoutSeconds);
}
// --- HTTP ---
@@ -159,24 +235,50 @@ function api(path, opt){
// --- WiFi scan view ---
var _wmWifiScanPollTimer=null;
var _wmWifiScanPollToken=0;
function getScanResultsBox(){
return $('wm-scan-results');
}
function setScanRefreshEnabled(enabled){
var btn=$('wm-refresh-scan');
if(btn)btn.disabled=(enabled===false);
}
function stopWifiScanPolling(){
_wmWifiScanPollToken++;
if(_wmWifiScanPollTimer){
clearInterval(_wmWifiScanPollTimer);
clearTimeout(_wmWifiScanPollTimer);
_wmWifiScanPollTimer=null;
}
setScanRefreshEnabled(true);
}
function startWifiScanPolling(box){
stopWifiScanPolling();
_wmWifiScanPollTimer=setInterval(function(){
setScanRefreshEnabled(false);
var token=++_wmWifiScanPollToken;
function poll(){
api('/api/wifi/scan-status').then(function(res){
if(token!==_wmWifiScanPollToken)return;
var d={};
try{d=JSON.parse(res.body);}catch(e){return;}
box=getScanResultsBox()||box;
if(box)box.innerHTML=renderScanList(d);
if(!d.scanning)stopWifiScanPolling();
if(!d.scanning){
stopWifiScanPolling();
return;
}
_wmWifiScanPollTimer=setTimeout(poll,800);
}).catch(function(){
if(token!==_wmWifiScanPollToken)return;
stopWifiScanPolling();
box=getScanResultsBox()||box;
if(box)box.innerHTML=renderScanError('Portal became unreachable while scanning.');
});
},800);
}
poll();
}
function renderScanList(data){
@@ -197,23 +299,42 @@ function renderScanList(data){
n=data.networks[i];
pct=(n.quality||0)+'%';
qi=Math.round((n.quality/100)*3)+1;if(qi<1)qi=1;if(qi>4)qi=4;
enc=n.encrypted?'l':'';
html+="<a class='wm-scan-row' href='#p' data-ssid='"+esc(n.ssid)+"'>";
html+="<span class='wm-scan-ssid'>"+esc(n.ssid)+"</span>";
html+="<span role='img' aria-label='"+pct+"' title='"+pct+"' class='q q-"+qi+" "+enc+"'></span>";
html+="<span class='wm-scan-meta'>";
if(n.encrypted){html+="<span class='wm-scan-lock q l' aria-hidden='true'></span>";}
html+="<span role='img' aria-label='"+pct+"' title='"+pct+"' class='wm-scan-bars q q-"+qi+"'></span>";
html+="<span class='wm-scan-signal'>"+pct+"</span>";
html+="</span>";
html+="</a>";
}
html+="</div>";
return html;
}
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 wifiRefresh(){
var box=document.getElementById('wm-scan-results');
var box=getScanResultsBox();
if(!box)return;
setScanRefreshEnabled(false);
box.innerHTML="<div class='wm-scan-list'><p class='wm-lead' style='margin:0;padding:14px 12px'>Starting scan…</p></div>";
api('/api/wifi/scan',{method:'POST'}).then(function(){
api('/api/wifi/scan',{method:'POST'}).then(function(res){
box=getScanResultsBox()||box;
if(!res.ok){
setScanRefreshEnabled(true);
if(box)box.innerHTML=renderScanError('Failed to start WiFi scan.');
showToast('Failed to start WiFi scan.',true);
return;
}
startWifiScanPolling(box);
}).catch(function(){
stopWifiScanPolling();
box=getScanResultsBox()||box;
if(box)box.innerHTML=renderScanError('Failed to reach portal while starting scan.');
showToast('Failed to reach portal while starting scan.',true);
});
}
@@ -273,12 +394,19 @@ function viewWifi(){
html+="<div id='wm-wifi-msg'></div></div>";
setView(html);
bindWifiViewEvents();
setScanRefreshEnabled(true);
api('/api/wifi/scan-status').then(function(r2){
try{var d=JSON.parse(r2.body);}catch(e){d={};}
var box=document.getElementById('wm-scan-results');
var box=getScanResultsBox();
if(box)box.innerHTML=renderScanList(d);
if(d&&d.scanning){startWifiScanPolling(box);}
}).catch(function(){
var box=getScanResultsBox();
if(box)box.innerHTML=renderScanError('Failed to reach portal while loading scan status.');
setScanRefreshEnabled(true);
});
}).catch(function(){
setView(navBar('wifi')+"<div class='wm-card'><p class='wm-lead'>Failed to load WiFi options. Reconnect to the portal and try again.</p></div>");
});
}
@@ -310,6 +438,10 @@ function pollWifiConnectStatus(){
stopWifiConnectPolling();
showToast(j.message||'WiFi connect failed',true);
}
}).catch(function(){
stopWifiConnectPolling();
var msg=$('wm-wifi-msg');
if(msg)msg.innerHTML='Portal became unreachable while checking connection status.';
});
},700);
}
@@ -363,6 +495,15 @@ function viewInfo(){
html+=section('Device',d.device||[]);
html+=section('WiFi',d.wifi||[]);
html+=section('About',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);
}
}
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>";
@@ -426,10 +567,11 @@ window.portalParamSave=function(ev){
};
function viewUpdate(){
var f=boot.features||{};
var pages=boot.pages||{};
var pupd=pages.update||{};
var html=navBar('update');
html+="<div class='wm-page-head'><h1>Firmware update</h1><p class='wm-page-desc'>Flash a new firmware build (.bin). The device reboots when done.</p></div>";
if(!f.showUpdate){
if(!pupd.visible){
html+="<div class='wm-card'><p class='wm-lead'>Firmware update is disabled for this configuration.</p></div>";
setView(html);
return;
@@ -484,19 +626,29 @@ window.portalOtaSubmit=function(ev){
function route(){
var h=location.hash||'#/';
if(h.indexOf('#/')!==0)h='#/';
var f=boot.features||{};
if(h==='#/info'&&f.showInfo===false){viewHome();return;}
if(h==='#/update'&&f.showUpdate===false){viewHome();return;}
if(h==='#/'||h==='#'){viewHome();return;}
if(h==='#/wifi'){viewWifi();return;}
if(h==='#/info'){viewInfo();return;}
if(h==='#/setup'){viewSetup();return;}
if(h==='#/update'){viewUpdate();return;}
viewHome();
var pages=boot.pages||{};
var ps=pages.setup||{};
var pi=pages.info||{};
var pu=pages.update||{};
if(h==='#/setup'&&ps.visible===false){viewHome();try{document.dispatchEvent(new CustomEvent('wm:view-changed',{detail:{route:'#/'}}));}catch(e){}return;}
if(h==='#/info'&&pi.visible===false){viewHome();try{document.dispatchEvent(new CustomEvent('wm:view-changed',{detail:{route:'#/'}}));}catch(e){}return;}
if(h==='#/update'&&pu.visible===false){viewHome();try{document.dispatchEvent(new CustomEvent('wm:view-changed',{detail:{route:'#/'}}));}catch(e){}return;}
if(h==='#/'||h==='#'){viewHome();}
else if(h==='#/wifi'){viewWifi();}
else if(h==='#/info'){viewInfo();}
else if(h==='#/setup'){viewSetup();}
else if(h==='#/update'){viewUpdate();}
else {viewHome();h='#/';}
try{document.dispatchEvent(new CustomEvent('wm:view-changed',{detail:{route:h}}));}catch(e){}
}
function bootPortal(){
route();
try{document.dispatchEvent(new CustomEvent('wm:ready',{detail:{boot:boot}}));}catch(e){}
}
window.addEventListener('hashchange',route);
window.addEventListener('load',route);
window.addEventListener('load',bootPortal);
})();
)rawliteral";
@@ -7,9 +7,8 @@
* Single-page portal shell: only HTML document served for GET /.
* Dynamic UI is driven by embedded JS + JSON APIs under /api/...
*
* Placeholder keys (%PAGE_TITLE%, %STYLES%, %BOOTSTRAP_JSON%, %PORTAL_APP_JS%) are filled per request in
* WiFiManagerHandlers::handleRoot (shell contract comment in WiFiManagerHandlers.cpp); do not treat
* placeholders as a customization API.
* Placeholder keys (%PAGE_TITLE%, %STYLES%, %BOOTSTRAP_JSON%, %PORTAL_APP_JS%, %PORTAL_APPEND_JS%) are filled
* per request in WiFiManagerHandlers::handleRoot; do not treat placeholders as a customization API.
*/
#ifndef _WM_ROOT_SHELL_TEMPLATE_H_
@@ -42,6 +41,9 @@ const char WM_ROOT_SHELL_TEMPLATE[] PROGMEM =
"<script>"
"%PORTAL_APP_JS%"
"</script>"
"<script>"
"%PORTAL_APPEND_JS%"
"</script>"
"</body>"
"</html>";
+148 -50
View File
@@ -62,7 +62,7 @@ bool normalizeHostname(String& hostname) {
* @param p Pointer to WiFiManagerParameter to add
* @return true if added successfully, false on error
*/
bool WiFiManager::addParameter(WiFiManagerParameter *p) {
bool WiFiManager::portalAddParameter(WiFiManagerParameter *p) {
// check param id is valid, unless null
if(p->getID()){
@@ -111,6 +111,15 @@ bool WiFiManager::addParameter(WiFiManagerParameter *p) {
return true;
}
void WiFiManager::portalClearParameters() {
if (_params != NULL) {
free(_params);
_params = NULL;
}
_paramsCount = 0;
_max_params = WIFI_MANAGER_MAX_PARAMS;
}
WiFiManagerParameter** WiFiManager::getParameters() {
return _params;
}
@@ -1927,29 +1936,6 @@ void WiFiManager::setShowDnsFields(boolean alwaysShow){
else _staShowDns = alwaysShow ? 1 : 0;
}
/**
* toggle showing password in wifi password field
* if not enabled, placeholder will be S_passph
* @since $dev
* @access public
* @param boolean alwaysShow [false]
*/
void WiFiManager::setShowPassword(boolean show){
_showPassword = show;
}
/**
* toggle captive portal
* if enabled, then devices that use captive portal checks will be redirected to root
* if not you will automatically have to navigate to ip [192.168.4.1]
* @since $dev
* @access public
* @param boolean enabled [true]
*/
void WiFiManager::setCaptivePortalEnable(boolean enabled){
_enableCaptivePortal = enabled;
}
/**
* toggle wifi autoreconnect policy
* if enabled, then wifi will autoreconnect automatically always
@@ -2076,24 +2062,148 @@ void WiFiManager::setWiFiAPHidden(bool hidden){
}
/**
* toggle showing erase wifi config button on info page
* @param boolean enabled
*/
void WiFiManager::setShowInfoErase(boolean enabled){
_showInfoErase = enabled;
void WiFiManager::portalSetBrandTitle(const String& title) {
_portalBrand.title = title;
}
/**
* toggle showing update upload web ota button on info page
* @param boolean enabled
*/
void WiFiManager::setShowInfoUpdate(boolean enabled){
_showInfoUpdate = enabled;
void WiFiManager::portalSetContextIdentityText(const String& identityText) {
_portalBrand.identityTextOverride = identityText;
}
void WiFiManager::setShowInfo(boolean enabled){
_showInfo = enabled;
void WiFiManager::portalSetBrandHomeIntro(const String& text) {
_portalBrand.homeIntro = text;
}
void WiFiManager::portalSetBrandLogoSvg(const String& svgMarkup) {
_portalBrand.logoSvg = svgMarkup;
}
void WiFiManager::portalSetPageInfoVisible(bool visible) {
_portalPages.infoVisible = visible;
}
void WiFiManager::portalSetPageUpdateVisible(bool visible) {
_portalPages.updateVisible = visible;
}
void WiFiManager::portalSetPageSetupVisible(bool visible) {
_portalPages.setupVisible = visible;
}
void WiFiManager::portalSetActionEraseVisible(bool visible) {
_portalActions.eraseVisible = visible;
}
void WiFiManager::portalSetActionRestartVisible(bool visible) {
_portalActions.restartVisible = visible;
}
void WiFiManager::portalSetActionExitVisible(bool visible) {
_portalActions.exitVisible = visible;
}
void WiFiManager::portalSetActionCloseCaptiveVisible(bool visible) {
_portalActions.closeCaptiveVisible = visible;
}
void WiFiManager::portalSetActionBackVisible(bool visible) {
_portalActions.backVisible = visible;
}
void WiFiManager::portalSetLayoutParamsLocation(PortalParamsLocation location) {
_portalLayout.paramsOnWifiPage = (location == PortalParamsLocation::WiFiPage);
}
void WiFiManager::portalSetBehaviorCaptivePortalEnabled(bool enabled) {
_enableCaptivePortal = enabled;
}
void WiFiManager::portalSetBehaviorConnectOnSave(bool enabled) {
setSaveConnect(enabled);
}
void WiFiManager::portalSetBehaviorExitAllowed(bool allowed) {
_allowExit = allowed;
}
void WiFiManager::portalSetBehaviorConnectTimeoutSeconds(unsigned long seconds) {
setConnectTimeout(seconds);
}
void WiFiManager::portalSetBehaviorPortalTimeoutSeconds(unsigned long seconds) {
setConfigPortalTimeout(seconds);
}
void WiFiManager::portalSetBehaviorAutoReconnect(bool enabled) {
setWiFiAutoReconnect(enabled);
}
void WiFiManager::portalSetBehaviorApClientCheck(bool enabled) {
setAPClientCheck(enabled);
}
void WiFiManager::portalSetBehaviorWebClientCheck(bool enabled) {
setWebPortalClientCheck(enabled);
}
void WiFiManager::portalSetFieldPasswordPlaceholderMode(PortalPasswordPlaceholderMode mode) {
_portalPasswordPlaceholderMode = mode;
}
void WiFiManager::portalSetFieldStaticIpVisibility(PortalFieldVisibility visibility) {
switch (visibility) {
case PortalFieldVisibility::Always:
setShowStaticFields(true);
break;
case PortalFieldVisibility::Auto:
setShowStaticFields(false);
break;
case PortalFieldVisibility::Hidden:
_staShowStaticFields = -1;
break;
}
}
void WiFiManager::portalSetFieldStaticDnsVisibility(PortalFieldVisibility visibility) {
switch (visibility) {
case PortalFieldVisibility::Always:
setShowDnsFields(true);
break;
case PortalFieldVisibility::Auto:
setShowDnsFields(false);
break;
case PortalFieldVisibility::Hidden:
_staShowDns = -1;
break;
}
}
void WiFiManager::portalAddInfoSection(const PortalInfoSection& section) {
_portalStructured.infoSections.push_back(section);
}
void WiFiManager::portalClearInfoSections() {
_portalStructured.infoSections.clear();
}
void WiFiManager::portalAddHomeCard(const PortalHomeCard& card) {
_portalStructured.homeCards.push_back(card);
}
void WiFiManager::portalClearHomeCards() {
_portalStructured.homeCards.clear();
}
void WiFiManager::portalAppendCss(const String& css) {
_portalAssets.appendedCss += css;
}
void WiFiManager::portalOverrideCss(const String& css) {
_portalAssets.overriddenCss = css;
}
void WiFiManager::portalAppendJs(const String& js) {
_portalAssets.appendedJs += js;
}
/**
@@ -2174,18 +2284,6 @@ DNSServer* WiFiManager::getDNSServer() {
return _serverManager ? _serverManager->getDNSServer() : nullptr;
}
/**
* [setTitle description]
* @param String title, set app title
*/
void WiFiManager::setTitle(String title){
_title = title;
}
void WiFiManager::setParametersEmbeddedInWifi(boolean embedded) {
_paramsInWifi = embedded;
}
// GETTERS
/**
+251 -81
View File
@@ -13,7 +13,6 @@
#include "templates/PortalAppJS.h"
#include <TemplateEngine.h>
#include <cstring>
#if defined(ESP8266) || defined(ESP32)
#ifndef WM_PAGE_RESERVE_BYTES
@@ -54,12 +53,42 @@ size_t dynamicStringLengthGetter(const char* data, void* /*userData*/) {
return data ? strlen(data) : 0;
}
void configureDynamicStringDescriptor(DynamicTemplateDescriptor& descriptor, String& value) {
void configureDynamicStringDescriptor(DynamicDataDescriptor& descriptor, String& value) {
descriptor.getter = &dynamicStringGetter;
descriptor.getLength = &dynamicStringLengthGetter;
descriptor.userData = &value;
}
String readProgmemString(const char* p) {
return String(FPSTR(p));
}
String stripStyleWrapper(const String& styleBlock) {
String css = styleBlock;
const String openTag = F("<style>");
const String closeTag = F("</style>");
if (css.startsWith(openTag)) {
css.remove(0, openTag.length());
}
if (css.endsWith(closeTag)) {
css.remove(css.length() - closeTag.length());
}
return css;
}
const char* portalHomeCardKindJson(PortalHomeCardKind k) {
switch (k) {
case PortalHomeCardKind::Text:
return "text";
case PortalHomeCardKind::Callout:
return "callout";
case PortalHomeCardKind::KeyValue:
default:
return "kv";
}
}
template <typename BundleT>
AsyncWebServerResponse* beginTemplateResponse(AsyncWebServerRequest* request,
const std::shared_ptr<BundleT>& bundle,
@@ -73,41 +102,123 @@ AsyncWebServerResponse* beginTemplateResponse(AsyncWebServerRequest* request,
);
}
// Shell contract (must match templates/RootShell.h — customize UI via WiFiManager APIs + bootstrap JSON, not registries):
// %PAGE_TITLE% -> document title (from WiFiManager title state)
// %STYLES% -> embedded portal CSS
// %BOOTSTRAP_JSON% -> initial SPA runtime payload
// %PORTAL_APP_JS% -> embedded SPA source
// Shell contract (must match templates/RootShell.h — customize UI via WiFiManager portal* APIs + bootstrap JSON, not registries):
// %PAGE_TITLE% -> document title (from WiFiManager title state)
// %STYLES% -> portal CSS (embedded default + portalAppendCss / portalOverrideCss)
// %BOOTSTRAP_JSON% -> initial SPA runtime payload
// %PORTAL_APP_JS% -> embedded SPA source
// %PORTAL_APPEND_JS% -> consumer hook script (portalAppendJs)
struct PortalShellRenderBundle {
String bootstrapJson;
String pageTitleStatic;
String stylesStatic;
String appendJsStatic;
PlaceholderRegistry registry;
TemplateContext context;
DynamicTemplateDescriptor bootstrapDescriptor;
DynamicTemplateDescriptor pageTitleDescriptor;
DynamicDataDescriptor bootstrapDescriptor;
DynamicDataDescriptor pageTitleDescriptor;
DynamicDataDescriptor stylesDescriptor;
DynamicDataDescriptor appendJsDescriptor;
PortalShellRenderBundle()
: registry(WM_TEMPLATE_REGISTRY_CAPACITY), bootstrapDescriptor{}, pageTitleDescriptor{} {}
: registry(WM_TEMPLATE_REGISTRY_CAPACITY),
bootstrapDescriptor{},
pageTitleDescriptor{},
stylesDescriptor{},
appendJsDescriptor{} {}
};
void populatePortalShellBundle(PortalShellRenderBundle& bundle,
const String& pageTitle,
const String& bootstrapJson) {
bundle.pageTitleStatic = pageTitle;
bundle.bootstrapJson = bootstrapJson;
bundle.registry.registerProgmemData("%STYLES%", CSS_STYLE);
bundle.registry.registerProgmemData("%PORTAL_APP_JS%", PORTAL_APP_JS);
configureDynamicStringDescriptor(bundle.pageTitleDescriptor, bundle.pageTitleStatic);
bundle.registry.registerDynamicTemplate("%PAGE_TITLE%", &bundle.pageTitleDescriptor);
configureDynamicStringDescriptor(bundle.bootstrapDescriptor, bundle.bootstrapJson);
bundle.registry.registerDynamicTemplate("%BOOTSTRAP_JSON%", &bundle.bootstrapDescriptor);
}
} // namespace
WiFiManagerHandlers::WiFiManagerHandlers(WiFiManager* wm) : _wm(wm) {}
String WiFiManagerHandlers::composePortalStylesheet() const {
String css;
if (_wm != nullptr && _wm->_portalAssets.overriddenCss.length() > 0) {
css = _wm->_portalAssets.overriddenCss;
} else {
css = stripStyleWrapper(readProgmemString(CSS_STYLE));
}
if (_wm != nullptr && _wm->_portalAssets.appendedCss.length() > 0) {
css += _wm->_portalAssets.appendedCss;
}
String out = F("<style>");
out += css;
out += F("</style>");
return out;
}
void WiFiManagerHandlers::appendPortalExtraInfoSectionsJson(String& json, bool& first) {
if (_wm == nullptr) {
return;
}
for (const auto& sec : _wm->_portalStructured.infoSections) {
if (!first) {
json += F(",");
}
first = false;
json += F("{\"id\":\"");
jsonAppendEscaped(json, sec.id);
json += F("\",\"title\":\"");
jsonAppendEscaped(json, sec.title);
json += F("\",\"items\":[");
bool firstItem = true;
for (const auto& it : sec.items) {
if (!firstItem) {
json += F(",");
}
firstItem = false;
json += F("{\"key\":\"");
jsonAppendEscaped(json, it.key);
json += F("\",\"label\":\"");
jsonAppendEscaped(json, it.label);
json += F("\",\"value\":\"");
jsonAppendEscaped(json, it.value);
json += F("\"}");
}
json += F("]}");
}
}
void WiFiManagerHandlers::appendPortalExtraHomeCardsJson(String& json, bool& first) {
if (_wm == nullptr) {
return;
}
for (const auto& card : _wm->_portalStructured.homeCards) {
if (!first) {
json += F(",");
}
first = false;
json += F("{\"id\":\"");
jsonAppendEscaped(json, card.id);
json += F("\",\"title\":\"");
jsonAppendEscaped(json, card.title);
json += F("\",\"kind\":\"");
json += portalHomeCardKindJson(card.kind);
json += F("\",\"text\":\"");
jsonAppendEscaped(json, card.text);
json += F("\",\"items\":[");
bool firstItem = true;
for (const auto& it : card.items) {
if (!firstItem) {
json += F(",");
}
firstItem = false;
json += F("{\"key\":\"");
jsonAppendEscaped(json, it.key);
json += F("\",\"label\":\"");
jsonAppendEscaped(json, it.label);
json += F("\",\"value\":\"");
jsonAppendEscaped(json, it.value);
json += F("\"}");
}
json += F("]}");
}
}
void WiFiManagerHandlers::collectVisibleScanResults(std::vector<const WiFiManager::WiFiScanNetwork*>& networks) {
networks.clear();
networks.reserve(_wm->_scanResultsCache.size());
@@ -197,7 +308,7 @@ void WiFiManagerHandlers::appendPortalJsonCustomParams(String& json, bool& first
first = false;
if (p->getID() != nullptr) {
String pid = String(p->getID());
json += F("{\"name\":\"");
json += F("{\"kind\":\"field\",\"name\":\"");
jsonAppendEscaped(json, pid);
json += F("\",\"id\":\"");
jsonAppendEscaped(json, pid);
@@ -219,7 +330,7 @@ void WiFiManagerHandlers::appendPortalJsonCustomParams(String& json, bool& first
}
json += F("}");
} else {
json += F("{\"html\":\"");
json += F("{\"kind\":\"html\",\"html\":\"");
jsonAppendEscaped(json, String(p->getCustomHTML()));
json += F("}");
}
@@ -560,9 +671,25 @@ void WiFiManagerHandlers::handleRoot(AsyncWebServerRequest *request) {
// - Populate one request-scoped PlaceholderRegistry with shell defaults + request payloads.
// - Render WM_ROOT_SHELL_TEMPLATE.
auto bundle = std::make_shared<PortalShellRenderBundle>();
populatePortalShellBundle(*bundle,
_wm ? _wm->_title : String(),
buildPortalBootstrapJson());
const bool hasCustomStyles = _wm != nullptr &&
(_wm->_portalAssets.overriddenCss.length() > 0 || _wm->_portalAssets.appendedCss.length() > 0);
bundle->pageTitleStatic = _wm ? _wm->_portalBrand.title : String();
bundle->bootstrapJson = buildPortalBootstrapJson();
bundle->appendJsStatic = _wm ? _wm->_portalAssets.appendedJs : String();
bundle->registry.registerProgmemData("%PORTAL_APP_JS%", PORTAL_APP_JS);
if (hasCustomStyles) {
bundle->stylesStatic = composePortalStylesheet();
configureDynamicStringDescriptor(bundle->stylesDescriptor, bundle->stylesStatic);
bundle->registry.registerDynamicData("%STYLES%", &bundle->stylesDescriptor);
} else {
bundle->registry.registerProgmemData("%STYLES%", CSS_STYLE);
}
configureDynamicStringDescriptor(bundle->appendJsDescriptor, bundle->appendJsStatic);
bundle->registry.registerDynamicData("%PORTAL_APPEND_JS%", &bundle->appendJsDescriptor);
configureDynamicStringDescriptor(bundle->pageTitleDescriptor, bundle->pageTitleStatic);
bundle->registry.registerDynamicData("%PAGE_TITLE%", &bundle->pageTitleDescriptor);
configureDynamicStringDescriptor(bundle->bootstrapDescriptor, bundle->bootstrapJson);
bundle->registry.registerDynamicData("%BOOTSTRAP_JSON%", &bundle->bootstrapDescriptor);
request->send(beginTemplateResponse(request, bundle, WM_ROOT_SHELL_TEMPLATE));
}
@@ -634,7 +761,7 @@ void WiFiManagerHandlers::applyWifiAndParamsFromRequest(AsyncWebServerRequest *r
_wm->_presavewificallback();
}
if (_wm->_paramsInWifi) {
if (_wm->_portalLayout.paramsOnWifiPage) {
doParamSave(requestArgs);
}
}
@@ -821,38 +948,20 @@ void WiFiManagerHandlers::sendApiJson(AsyncWebServerRequest *request, int code,
request->send(response);
}
void WiFiManagerHandlers::appendPortalUiFeatureFlagsJson(String& json) {
const bool portalRunning = _wm->configPortalActive || _wm->webPortalActive;
json += F("\"showInfo\":");
json += _wm->_showInfo ? F("true") : F("false");
json += F(",\"showUpdate\":");
json += _wm->_showInfoUpdate ? F("true") : F("false");
json += F(",\"showErase\":");
json += _wm->_showInfoErase ? F("true") : F("false");
json += F(",\"paramsInWifi\":");
json += _wm->_paramsInWifi ? F("true") : F("false");
json += F(",\"showRestart\":");
json += portalRunning ? F("true") : F("false");
json += F(",\"showExitPortal\":");
json += (_wm->_allowExit && portalRunning) ? F("true") : F("false");
json += F(",\"showCloseCaptive\":");
json += (_wm->_enableCaptivePortal && _wm->configPortalActive) ? F("true") : F("false");
}
void WiFiManagerHandlers::appendApiInfoActionsJson(String& json) {
const bool portalRunning = _wm->configPortalActive || _wm->webPortalActive;
json += F("\"showUpdate\":");
json += _wm->_showInfoUpdate ? F("true") : F("false");
json += _wm->_portalPages.updateVisible ? F("true") : F("false");
json += F(",\"showErase\":");
json += _wm->_showInfoErase ? F("true") : F("false");
json += _wm->_portalActions.eraseVisible ? F("true") : F("false");
json += F(",\"showBack\":");
json += _wm->_showBack ? F("true") : F("false");
json += _wm->_portalActions.backVisible ? F("true") : F("false");
json += F(",\"showRestart\":");
json += portalRunning ? F("true") : F("false");
json += (_wm->_portalActions.restartVisible && portalRunning) ? F("true") : F("false");
json += F(",\"showExitPortal\":");
json += (_wm->_allowExit && portalRunning) ? F("true") : F("false");
json += (_wm->_portalActions.exitVisible && _wm->_allowExit && portalRunning) ? F("true") : F("false");
json += F(",\"showCloseCaptive\":");
json += (_wm->_enableCaptivePortal && _wm->configPortalActive) ? F("true") : F("false");
json += (_wm->_portalActions.closeCaptiveVisible && _wm->_enableCaptivePortal && _wm->configPortalActive) ? F("true") : F("false");
}
String WiFiManagerHandlers::buildPortalBootstrapJson() {
@@ -882,38 +991,87 @@ String WiFiManagerHandlers::buildPortalBootstrapJson() {
stateStr = "idle";
break;
}
const bool portalRunning = _wm->configPortalActive || _wm->webPortalActive;
String json;
reservePage(json, 512);
json += F("{");
reservePage(json, 1200);
json += F("{\"contractVersion\":2");
json += F(",\"brand\":{");
json += F("\"title\":\"");
jsonAppendEscaped(json, _wm->_title);
json += F("\",\"subtitle\":\"");
jsonAppendEscaped(json, _wm->_portalBrand.title);
json += F("\",\"homeIntro\":\"");
jsonAppendEscaped(json, _wm->_portalBrand.homeIntro);
json += F("\",\"logoSvg\":\"");
jsonAppendEscaped(json, _wm->_portalBrand.logoSvg);
json += F("\"}");
json += F(",\"context\":{");
json += F("\"portalActive\":");
json += portalRunning ? F("true") : F("false");
json += F(",\"portalTimeoutSecondsRemaining\":");
{
String sub;
if (_wm->configPortalActive) {
sub = _wm->_apName;
} else {
sub = _wm->getWiFiHostname() + " - " + WiFi.localIP().toString();
unsigned long timeoutRemainingSeconds = 0;
if (_wm->configPortalActive && _wm->_configPortalTimeout > 0) {
const unsigned long now = millis();
const unsigned long timeoutAt = _wm->_configPortalStart + _wm->_configPortalTimeout;
if (timeoutAt > now) {
timeoutRemainingSeconds = (timeoutAt - now + 999UL) / 1000UL;
}
}
jsonAppendEscaped(json, sub);
json += String(timeoutRemainingSeconds);
}
json += F("\",\"portalActive\":");
json += _wm->configPortalActive ? F("true") : F("false");
json += F(",\"features\":{");
appendPortalUiFeatureFlagsJson(json);
json += F("},\"showBack\":");
json += _wm->_showBack ? F("true") : F("false");
json += F(",\"scan\":{\"state\":\"");
json += stateStr;
json += F("\",\"count\":");
json += String((unsigned int)networks.size());
json += F("},\"initialStatus\":\"");
json += F(",\"identityText\":\"");
{
String idText;
if (_wm->_portalBrand.identityTextOverride.length() > 0) {
idText = _wm->_portalBrand.identityTextOverride;
} else if (_wm->configPortalActive) {
idText = _wm->_apName;
} else {
idText = _wm->getWiFiHostname() + " - " + WiFi.localIP().toString();
}
jsonAppendEscaped(json, idText);
}
json += F("\",\"statusSummary\":\"");
{
String st;
buildPlainStatusSummary(st);
jsonAppendEscaped(json, st);
}
json += F("\",\"scan\":{\"state\":\"");
json += stateStr;
json += F("\",\"count\":");
json += String((unsigned int)networks.size());
json += F("}}");
json += F(",\"pages\":{");
json += F("\"wifi\":{\"visible\":true}");
json += F(",\"setup\":{\"visible\":");
json += (!_wm->_portalLayout.paramsOnWifiPage && _wm->_portalPages.setupVisible) ? F("true") : F("false");
json += F("}");
json += F(",\"info\":{\"visible\":");
json += _wm->_portalPages.infoVisible ? F("true") : F("false");
json += F("}");
json += F(",\"update\":{\"visible\":");
json += _wm->_portalPages.updateVisible ? F("true") : F("false");
json += F("}}");
json += F(",\"actions\":{");
json += F("\"erase\":{\"visible\":");
json += _wm->_portalActions.eraseVisible ? F("true") : F("false");
json += F("},\"restart\":{\"visible\":");
json += (_wm->_portalActions.restartVisible && portalRunning) ? F("true") : F("false");
json += F("},\"exitPortal\":{\"visible\":");
json += (_wm->_portalActions.exitVisible && _wm->_allowExit && portalRunning) ? F("true") : F("false");
json += F("},\"closeCaptive\":{\"visible\":");
json += (_wm->_portalActions.closeCaptiveVisible && _wm->_enableCaptivePortal && _wm->configPortalActive) ? F("true") : F("false");
json += F("},\"back\":{\"visible\":");
json += _wm->_portalActions.backVisible ? F("true") : F("false");
json += F("}}");
json += F(",\"layout\":{\"paramsLocation\":\"");
json += _wm->_portalLayout.paramsOnWifiPage ? F("wifi") : F("setup");
json += F("\"}");
bool first = true;
json += F(",\"extraHomeCards\":[");
appendPortalExtraHomeCardsJson(json, first);
json += F("]}");
return json;
}
@@ -1019,10 +1177,19 @@ void WiFiManagerHandlers::handleApiWifiScan(AsyncWebServerRequest *request) {
String WiFiManagerHandlers::buildApiWifiMetaJson() {
String ssidPlaceholder = _wm->WiFi_SSID();
String passwordPlaceholder = "";
if (_wm->_showPassword) {
passwordPlaceholder = _wm->WiFi_psk();
} else if (_wm->WiFi_psk() != "") {
passwordPlaceholder = F("********");
switch (_wm->_portalPasswordPlaceholderMode) {
case PortalPasswordPlaceholderMode::Actual:
passwordPlaceholder = _wm->WiFi_psk();
break;
case PortalPasswordPlaceholderMode::Masked:
if (_wm->WiFi_psk() != "") {
passwordPlaceholder = F("********");
}
break;
case PortalPasswordPlaceholderMode::Hidden:
default:
passwordPlaceholder = "";
break;
}
String json = F("{\"wifiFields\":[");
@@ -1035,11 +1202,11 @@ String WiFiManagerHandlers::buildApiWifiMetaJson() {
appendPortalJsonStaticFields(json, first);
json += F("],\"params\":[");
first = true;
if (_wm->_paramsInWifi && _wm->getParametersCount() > 0) {
if (_wm->_portalLayout.paramsOnWifiPage && _wm->getParametersCount() > 0) {
appendPortalJsonCustomParams(json, first);
}
json += F("],\"actions\":{\"canRefreshScan\":true,\"showBack\":");
json += _wm->_showBack ? F("true") : F("false");
json += _wm->_portalActions.backVisible ? F("true") : F("false");
json += F("}}");
return json;
}
@@ -1096,7 +1263,7 @@ String WiFiManagerHandlers::buildApiParamsGetJson() {
bool first = true;
appendPortalJsonCustomParams(json, first);
json += F("],\"actions\":{\"showBack\":");
json += _wm->_showBack ? F("true") : F("false");
json += _wm->_portalActions.backVisible ? F("true") : F("false");
json += F("}}");
return json;
}
@@ -1156,6 +1323,9 @@ String WiFiManagerHandlers::buildApiInfoJson() {
first = true;
static const char *const aboutIds[] = {"aboutver", "aboutarduinover", "aboutsdkver", "aboutdate"};
appendInfoSectionFromIds(json, aboutIds, sizeof(aboutIds) / sizeof(aboutIds[0]), first);
json += F("],\"extraSections\":[");
first = true;
appendPortalExtraInfoSectionsJson(json, first);
json += F("],\"actions\":{");
appendApiInfoActionsJson(json);
json += F("}}");
+2 -19
View File
@@ -8,7 +8,6 @@ TestCase tests[] = {
// Basic tests
TEST_ENTRY(test_basic_wifimanager_instantiation),
TEST_ENTRY(test_reset_settings),
TEST_ENTRY(test_disconnect),
// 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),
@@ -17,9 +16,7 @@ TestCase tests[] = {
TEST_ENTRY(test_api_status_json_shape),
// Configuration tests
TEST_ENTRY(test_configuration_setters),
TEST_ENTRY(test_set_and_get_hostname),
TEST_ENTRY(test_wifi_ap_configuration_setters),
TEST_ENTRY(test_get_default_ap_name),
TEST_ENTRY(test_get_wifi_status_string),
TEST_ENTRY(test_get_mode_string),
@@ -37,19 +34,18 @@ TestCase tests[] = {
TEST_ENTRY(test_config_portal_already_active),
TEST_ENTRY(test_get_config_portal_ssid),
TEST_ENTRY(test_bootstrap_json_portal_feature_flags),
TEST_ENTRY(test_bootstrap_json_contract_v2),
TEST_ENTRY(test_bootstrap_json_snapshot_consistency),
TEST_ENTRY(test_root_render_interleaved_context_isolation),
// Non-blocking tests
TEST_ENTRY(test_nonblocking_process),
TEST_ENTRY(test_client_check_setters),
TEST_ENTRY(test_nonblocking_timeout_behavior),
TEST_ENTRY(test_process_required_for_timeout),
// Callback tests
TEST_ENTRY(test_ap_callback),
TEST_ENTRY(test_web_server_callback),
TEST_ENTRY(test_callback_registration),
TEST_ENTRY(test_multiple_callbacks),
// Parameter tests
@@ -64,21 +60,11 @@ TestCase tests[] = {
// Static IP tests
TEST_ENTRY(test_set_ap_static_ip_config),
TEST_ENTRY(test_sta_static_ip_configuration),
TEST_ENTRY(test_ap_static_ip_application),
// Advanced options tests
TEST_ENTRY(test_connection_behavior_options),
TEST_ENTRY(test_ui_display_options),
TEST_ENTRY(test_ui_customization),
TEST_ENTRY(test_debug_soft_ap_config),
TEST_ENTRY(test_debug_platform_info),
// WiFi connection tests
TEST_ENTRY(test_autoconnect_fallback_to_portal),
TEST_ENTRY(test_connectwifi_ssid_not_found),
TEST_ENTRY(test_connectwifi_retry_count),
TEST_ENTRY(test_connectwifi_timeout_setting),
TEST_ENTRY(test_connection_state_transitions),
TEST_ENTRY(test_autoconnect_with_timeout),
@@ -94,10 +80,6 @@ TestCase tests[] = {
TEST_ENTRY(test_dns_server_lifecycle_cycles),
// Error condition tests
TEST_ENTRY(test_invalid_ap_password_too_short),
TEST_ENTRY(test_invalid_ap_password_too_long),
TEST_ENTRY(test_empty_ssid),
TEST_ENTRY(test_very_long_ssid),
TEST_ENTRY(test_connection_failure_handling),
TEST_ENTRY(test_resource_cleanup_after_error),
TEST_ENTRY(test_multiple_rapid_start_stop),
@@ -120,6 +102,7 @@ TestCase tests[] = {
// Template rendering tests
TEST_ENTRY(test_shell_template_renders_core_placeholders),
TEST_ENTRY(test_shell_template_renders_dynamic_styles_with_percent_values),
// State transition tests
TEST_ENTRY(test_portal_to_connected_transition),
+2 -17
View File
@@ -18,12 +18,9 @@ struct TestCase {
// Basic tests
void test_basic_wifimanager_instantiation();
void test_reset_settings();
void test_disconnect();
// Configuration tests
void test_configuration_setters();
void test_set_and_get_hostname();
void test_wifi_ap_configuration_setters();
void test_get_default_ap_name();
void test_get_wifi_status_string();
void test_get_mode_string();
@@ -41,19 +38,18 @@ void test_config_portal_multiple_start_stop();
void test_config_portal_already_active();
void test_get_config_portal_ssid();
void test_bootstrap_json_portal_feature_flags();
void test_bootstrap_json_contract_v2();
void test_bootstrap_json_snapshot_consistency();
void test_root_render_interleaved_context_isolation();
// Non-blocking tests
void test_nonblocking_process();
void test_client_check_setters();
void test_nonblocking_timeout_behavior();
void test_process_required_for_timeout();
// Callback tests
void test_ap_callback();
void test_web_server_callback();
void test_callback_registration();
void test_multiple_callbacks();
// Parameter tests
@@ -68,21 +64,13 @@ void test_parameter_placeholder();
// Static IP tests
void test_set_ap_static_ip_config();
void test_sta_static_ip_configuration();
void test_ap_static_ip_application();
// Advanced options tests
void test_connection_behavior_options();
void test_ui_display_options();
void test_ui_customization();
void test_debug_soft_ap_config();
void test_debug_platform_info();
// WiFi connection tests
void test_autoconnect_fallback_to_portal();
void test_connectwifi_ssid_not_found();
void test_connectwifi_retry_count();
void test_connectwifi_timeout_setting();
void test_connection_state_transitions();
void test_autoconnect_with_timeout();
@@ -98,10 +86,6 @@ void test_dns_server_cleanup();
void test_dns_server_lifecycle_cycles();
// Error condition tests
void test_invalid_ap_password_too_short();
void test_invalid_ap_password_too_long();
void test_empty_ssid();
void test_very_long_ssid();
void test_connection_failure_handling();
void test_resource_cleanup_after_error();
void test_multiple_rapid_start_stop();
@@ -124,6 +108,7 @@ void test_scan_generation_invalidated_on_reset();
// Template rendering tests
void test_shell_template_renders_core_placeholders();
void test_shell_template_renders_dynamic_styles_with_percent_values();
// API JSON + captive helper tests
void test_captive_redirect_host_rule();
@@ -27,6 +27,18 @@ void test_connection_behavior_options() {
wm.setEnableConfigPortal(false);
wm.setDisableConfigPortal(true);
wm.setDisableConfigPortal(false);
wm.portalSetBehaviorConnectOnSave(true);
wm.portalSetBehaviorConnectOnSave(false);
wm.portalSetBehaviorExitAllowed(true);
wm.portalSetBehaviorExitAllowed(false);
wm.portalSetBehaviorConnectTimeoutSeconds(15);
wm.portalSetBehaviorPortalTimeoutSeconds(20);
wm.portalSetBehaviorAutoReconnect(true);
wm.portalSetBehaviorAutoReconnect(false);
wm.portalSetBehaviorApClientCheck(true);
wm.portalSetBehaviorApClientCheck(false);
wm.portalSetBehaviorWebClientCheck(true);
wm.portalSetBehaviorWebClientCheck(false);
wm.preloadWiFi("TestSSID", "TestPassword");
// All setters executed without crash
@@ -42,16 +54,27 @@ void test_ui_display_options() {
WiFiManager wm;
// Test display toggles
wm.setShowPassword(true);
wm.setShowPassword(false);
wm.portalSetFieldPasswordPlaceholderMode(PortalPasswordPlaceholderMode::Actual);
wm.portalSetFieldPasswordPlaceholderMode(PortalPasswordPlaceholderMode::Masked);
wm.portalSetFieldPasswordPlaceholderMode(PortalPasswordPlaceholderMode::Hidden);
wm.setScanDispPerc(true);
wm.setScanDispPerc(false);
wm.setShowInfoErase(true);
wm.setShowInfoErase(false);
wm.setShowInfoUpdate(true);
wm.setShowInfoUpdate(false);
wm.setParametersEmbeddedInWifi(true);
wm.setParametersEmbeddedInWifi(false);
wm.portalSetActionEraseVisible(true);
wm.portalSetActionEraseVisible(false);
wm.portalSetActionRestartVisible(true);
wm.portalSetActionRestartVisible(false);
wm.portalSetActionExitVisible(true);
wm.portalSetActionExitVisible(false);
wm.portalSetActionCloseCaptiveVisible(true);
wm.portalSetActionCloseCaptiveVisible(false);
wm.portalSetPageUpdateVisible(true);
wm.portalSetPageUpdateVisible(false);
wm.portalSetPageSetupVisible(true);
wm.portalSetPageSetupVisible(false);
wm.portalSetLayoutParamsLocation(PortalParamsLocation::WiFiPage);
wm.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage);
wm.portalSetActionBackVisible(true);
wm.portalSetActionBackVisible(false);
// All setters executed without crash
TEST_ASSERT_TRUE_MESSAGE(true, "UI display options executed without crash");
@@ -66,7 +89,7 @@ void test_ui_customization() {
WiFiManager wm;
// Minimal customization retained: title
wm.setTitle("MyDevice");
wm.portalSetBrandTitle("MyDevice");
// All setters executed without crash
TEST_ASSERT_TRUE_MESSAGE(true, "UI customization options executed without crash");
@@ -29,6 +29,14 @@ void test_api_wifi_meta_json_shape() {
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"actions\"")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"canRefreshScan\":true")));
WiFiManagerParameter fieldParam("mqtt_host", "MQTT host", "broker.local", 64, "placeholder='broker.local'");
WiFiManagerParameter htmlParam("<div class='wm-callout wm-callout--info'><p>GPS settings...</p></div>");
wm.portalAddParameter(&fieldParam);
wm.portalAddParameter(&htmlParam);
j = handlers.buildApiWifiMetaJson();
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"kind\":\"field\"")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"kind\":\"html\"")));
Serial.println("[TEST] WiFi meta JSON shape test completed successfully");
}
@@ -37,6 +45,12 @@ void test_api_info_json_shape() {
WiFiManager wm;
WiFiManagerHandlers handlers(&wm);
PortalInfoSection battery;
battery.id = "battery";
battery.title = "Battery";
battery.items.push_back({"soc", "State of charge", "84%"});
battery.items.push_back({"voltage", "Voltage", "13.2V"});
wm.portalAddInfoSection(battery);
String j = handlers.buildApiInfoJson();
const char* p = j.c_str();
// Shape checks only: "status" is a nested object (},\n"device" not ],"device"); require
@@ -48,6 +62,10 @@ void test_api_info_json_shape() {
TEST_ASSERT_NOT_NULL(strstr(p, "\"status\":"));
TEST_ASSERT_NOT_NULL(strstr(p, "\"connected\":"));
TEST_ASSERT_NOT_NULL(strstr(p, "},\"device\":["));
TEST_ASSERT_NOT_NULL(strstr(p, "\"extraSections\":"));
TEST_ASSERT_NOT_NULL(strstr(p, "\"id\":\"battery\""));
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\":"));
Serial.println("[TEST] API info JSON shape test completed successfully");
+1 -7
View File
@@ -9,10 +9,7 @@ void test_basic_wifimanager_instantiation() {
// Test default constructor
WiFiManager wm;
// Verify instance created (basic sanity check)
TEST_ASSERT_TRUE_MESSAGE(true, "WiFiManager instance created successfully");
// Test that we can call basic methods without crashing
String defaultName = wm.getDefaultAPName();
TEST_ASSERT_GREATER_THAN(0, defaultName.length());
@@ -69,9 +66,6 @@ void test_disconnect() {
// Can be called multiple times safely
(void)wm.disconnect();
// Verify no crash (results may be true/false depending on WiFi state)
TEST_ASSERT_TRUE_MESSAGE(true, "disconnect() executed without crash");
Serial.println("[TEST] disconnect() test completed successfully");
}
@@ -36,26 +36,18 @@ void test_configuration_setters() {
wm.setShowDnsFields(false);
// Test portal behavior
wm.setCaptivePortalEnable(true);
wm.setCaptivePortalEnable(false);
wm.portalSetBehaviorCaptivePortalEnabled(true);
wm.portalSetBehaviorCaptivePortalEnabled(false);
// Test custom title (no getter available, but we can verify it doesn't crash)
wm.setTitle("TestTitle");
// Verify some settings can be retrieved (if getters exist)
// Note: Not all setters have getters, but we verify they don't crash
// All setters executed without crash
TEST_ASSERT_TRUE_MESSAGE(true, "All configuration setters executed without crash");
// Verify setters can be called multiple times with different values
// Test custom title path
wm.portalSetBrandTitle("TestTitle");
// Call setters multiple times with different values.
wm.setConfigPortalTimeout(60);
wm.setConnectTimeout(30);
wm.setHttpPort(8080);
wm.setMinimumSignalQuality(50);
// Verify no crash after multiple calls
TEST_ASSERT_TRUE_MESSAGE(true, "Configuration setters can be called multiple times");
Serial.println("[TEST] Configuration setters test completed successfully");
}
@@ -76,7 +68,6 @@ void test_set_and_get_hostname() {
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");
}
@@ -108,9 +99,6 @@ void test_wifi_ap_configuration_setters() {
wm.setWiFiAutoReconnect(true);
wm.setWiFiAutoReconnect(false);
// All setters executed without crash
TEST_ASSERT_TRUE_MESSAGE(true, "All WiFi AP configuration setters executed without crash");
Serial.println("[TEST] WiFi AP configuration setters test completed successfully");
}
@@ -172,10 +172,7 @@ void test_multiple_rapid_start_stop() {
wm.process();
delay(10);
}
// Should not crash
TEST_ASSERT_TRUE_MESSAGE(true, "Multiple rapid start/stop calls handled without crash");
// Final state should be stopped
TEST_ASSERT_FALSE(wm.getConfigPortalActive());
@@ -70,7 +70,7 @@ void test_parameter_add_and_retrieve() {
// 1. Add parameters
WiFiManagerParameter param("server", "Server", "default", 40);
wm.addParameter(&param);
wm.portalAddParameter(&param);
TEST_ASSERT_EQUAL(1, wm.getParametersCount());
@@ -135,8 +135,8 @@ void test_portal_with_parameters_and_infrastructure() {
// Add parameters
WiFiManagerParameter p1("server", "Server", "192.168.1.1", 40);
WiFiManagerParameter p2("port", "Port", "1883", 6);
wm.addParameter(&p1);
wm.addParameter(&p2);
wm.portalAddParameter(&p1);
wm.portalAddParameter(&p2);
// Start portal
wm.startConfigPortal("TestAP");
@@ -44,7 +44,7 @@ void test_parameter_value_persistence() {
WiFiManager wm;
WiFiManagerParameter param("server", "Server", "default", 40);
wm.addParameter(&param);
wm.portalAddParameter(&param);
// Set value
param.setValue("persisted_value", 40);
@@ -87,9 +87,9 @@ void test_multiple_parameters_different_values() {
WiFiManagerParameter p2("port", "Port", "1883", 6);
WiFiManagerParameter p3("key", "Key", "default_key", 32);
wm.addParameter(&p1);
wm.addParameter(&p2);
wm.addParameter(&p3);
wm.portalAddParameter(&p1);
wm.portalAddParameter(&p2);
wm.portalAddParameter(&p3);
// Set different values
p1.setValue("10.0.0.1", 40);
+12 -10
View File
@@ -20,7 +20,7 @@ void test_create_parameter() {
}
void test_add_parameter() {
Serial.println("[TEST] Testing addParameter()...");
Serial.println("[TEST] Testing portalAddParameter()...");
WiFiManager wm;
@@ -28,16 +28,18 @@ void test_add_parameter() {
WiFiManagerParameter param2("param2", "Parameter 2", "value2", 40);
// Add parameters
bool result1 = wm.addParameter(&param1);
bool result2 = wm.addParameter(&param2);
bool result1 = wm.portalAddParameter(&param1);
bool result2 = wm.portalAddParameter(&param2);
TEST_ASSERT_TRUE(result1);
TEST_ASSERT_TRUE(result2);
// Verify parameters are stored
TEST_ASSERT_EQUAL(2, wm.getParametersCount());
wm.portalClearParameters();
TEST_ASSERT_EQUAL(0, wm.getParametersCount());
Serial.println("[TEST] addParameter() test completed successfully");
Serial.println("[TEST] portalAddParameter() test completed successfully");
}
void test_get_parameters() {
@@ -49,9 +51,9 @@ void test_get_parameters() {
WiFiManagerParameter param2("param2", "Parameter 2", "value2", 40);
WiFiManagerParameter param3("param3", "Parameter 3", "value3", 40);
wm.addParameter(&param1);
wm.addParameter(&param2);
wm.addParameter(&param3);
wm.portalAddParameter(&param1);
wm.portalAddParameter(&param2);
wm.portalAddParameter(&param3);
// Get count
int count = wm.getParametersCount();
@@ -121,9 +123,9 @@ void test_multiple_parameters() {
WiFiManagerParameter p2("mqtt_port", "MQTT Port", "1883", 6);
WiFiManagerParameter p3("api_key", "API Key", "default_key", 32);
wm.addParameter(&p1);
wm.addParameter(&p2);
wm.addParameter(&p3);
wm.portalAddParameter(&p1);
wm.portalAddParameter(&p2);
wm.portalAddParameter(&p3);
TEST_ASSERT_EQUAL(3, wm.getParametersCount());
@@ -19,7 +19,7 @@ size_t dynamicStringLengthGetter(const char* data, void* /*userData*/) {
return data ? strlen(data) : 0;
}
void configureDescriptor(DynamicTemplateDescriptor& descriptor, String& value) {
void configureDescriptor(DynamicDataDescriptor& descriptor, String& value) {
descriptor.getter = &dynamicStringGetter;
descriptor.getLength = &dynamicStringLengthGetter;
descriptor.userData = &value;
@@ -62,20 +62,77 @@ void test_bootstrap_json_portal_feature_flags() {
WiFiManagerHandlers handlers(&wm);
String idleBootstrap = handlers.buildPortalBootstrapJson();
TEST_ASSERT_EQUAL(-1, idleBootstrap.indexOf(F("\"showCloseCaptive\":true")));
TEST_ASSERT_EQUAL(-1, idleBootstrap.indexOf(F("\"closeCaptive\":{\"visible\":true")));
wm.wmTestSetPortalActive(true);
wm.setCaptivePortalEnable(true);
wm.portalSetBehaviorCaptivePortalEnabled(true);
String apBootstrap = handlers.buildPortalBootstrapJson();
TEST_ASSERT_NOT_EQUAL(-1, apBootstrap.indexOf(F("\"showCloseCaptive\":true")));
TEST_ASSERT_NOT_EQUAL(-1, apBootstrap.indexOf(F("\"showExitPortal\":true")));
TEST_ASSERT_NOT_EQUAL(-1, apBootstrap.indexOf(F("\"closeCaptive\":{\"visible\":true")));
TEST_ASSERT_NOT_EQUAL(-1, apBootstrap.indexOf(F("\"exitPortal\":{\"visible\":true")));
wm.wmTestSetPortalActive(false);
Serial.println("[TEST] Bootstrap portal feature flags test completed successfully");
}
void test_bootstrap_json_contract_v2() {
Serial.println("[TEST] Testing bootstrap JSON v2 contract...");
WiFiManager wm;
WiFiManagerHandlers handlers(&wm);
wm.portalSetBrandTitle("Solar Battery Monitor Setup");
wm.portalSetContextIdentityText("Solar Battery Monitor");
wm.portalSetBrandHomeIntro("Connect this device to WiFi and finish setup.");
wm.portalSetBrandLogoSvg("<svg viewBox='0 0 24 24'></svg>");
wm.portalSetPageInfoVisible(true);
wm.portalSetPageUpdateVisible(false);
wm.portalSetPageSetupVisible(true);
wm.portalSetActionEraseVisible(false);
wm.portalSetActionBackVisible(true);
wm.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage);
wm.setConfigPortalTimeout(90);
PortalHomeCard card;
card.id = "solar";
card.title = "Solar summary";
card.kind = PortalHomeCardKind::KeyValue;
card.items.push_back({"pv", "PV input", "420W"});
wm.portalAddHomeCard(card);
String j = handlers.buildPortalBootstrapJson();
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"contractVersion\":2")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"brand\":{")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"title\":\"Solar Battery Monitor Setup\"")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"homeIntro\":\"Connect this device to WiFi and finish setup.\"")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"logoSvg\":\"<svg viewBox='0 0 24 24'></svg>\"")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"context\":{")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"identityText\":\"Solar Battery Monitor\"")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"pages\":{")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"setup\":{\"visible\":true}")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"actions\":{")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"erase\":{\"visible\":false}")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"back\":{\"visible\":true}")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"layout\":{\"paramsLocation\":\"setup\"}")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"extraHomeCards\":[")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"id\":\"solar\"")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"kind\":\"kv\"")));
wm.startWebPortal();
j = handlers.buildPortalBootstrapJson();
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"portalActive\":true")));
wm.stopWebPortal();
wm.wmTestSetPortalActive(true);
j = handlers.buildPortalBootstrapJson();
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"portalTimeoutSecondsRemaining\":")));
TEST_ASSERT_EQUAL(-1, j.indexOf(F("\"portalTimeoutSecondsRemaining\":0")));
wm.wmTestSetPortalActive(false);
Serial.println("[TEST] Bootstrap JSON v2 contract test completed successfully");
}
void test_bootstrap_json_snapshot_consistency() {
Serial.println("[TEST] Testing bootstrap JSON snapshot consistency...");
@@ -106,32 +163,34 @@ void test_root_render_interleaved_context_isolation() {
registryA.registerProgmemData("%STYLES%", kEmptyTemplateChunk);
registryA.registerProgmemData("%PAGE_TITLE%", kTestTitle);
registryA.registerProgmemData("%PORTAL_APPEND_JS%", kEmptyTemplateChunk);
registryB.registerProgmemData("%STYLES%", kEmptyTemplateChunk);
registryB.registerProgmemData("%PAGE_TITLE%", kTestTitle);
registryB.registerProgmemData("%PORTAL_APPEND_JS%", kEmptyTemplateChunk);
String bootstrapA = F("{\"ctx\":\"A\"}");
String appJsA = F("// shell A");
String bootstrapB = F("{\"ctx\":\"B\"}");
String appJsB = F("// shell B");
DynamicTemplateDescriptor bootstrapDescriptorA{};
DynamicTemplateDescriptor appJsDescriptorA{};
DynamicTemplateDescriptor bootstrapDescriptorB{};
DynamicTemplateDescriptor appJsDescriptorB{};
DynamicDataDescriptor bootstrapDescriptorA{};
DynamicDataDescriptor appJsDescriptorA{};
DynamicDataDescriptor bootstrapDescriptorB{};
DynamicDataDescriptor appJsDescriptorB{};
configureDescriptor(bootstrapDescriptorA, bootstrapA);
configureDescriptor(appJsDescriptorA, appJsA);
configureDescriptor(bootstrapDescriptorB, bootstrapB);
configureDescriptor(appJsDescriptorB, appJsB);
TEST_ASSERT_TRUE_MESSAGE(registryA.registerDynamicTemplate("%BOOTSTRAP_JSON%", &bootstrapDescriptorA),
TEST_ASSERT_TRUE_MESSAGE(registryA.registerDynamicData("%BOOTSTRAP_JSON%", &bootstrapDescriptorA),
"Registry A bootstrap placeholder should register");
TEST_ASSERT_TRUE_MESSAGE(registryA.registerDynamicTemplate("%PORTAL_APP_JS%", &appJsDescriptorA),
TEST_ASSERT_TRUE_MESSAGE(registryA.registerDynamicData("%PORTAL_APP_JS%", &appJsDescriptorA),
"Registry A app JS placeholder should register");
TEST_ASSERT_TRUE_MESSAGE(registryB.registerDynamicTemplate("%BOOTSTRAP_JSON%", &bootstrapDescriptorB),
TEST_ASSERT_TRUE_MESSAGE(registryB.registerDynamicData("%BOOTSTRAP_JSON%", &bootstrapDescriptorB),
"Registry B bootstrap placeholder should register");
TEST_ASSERT_TRUE_MESSAGE(registryB.registerDynamicTemplate("%PORTAL_APP_JS%", &appJsDescriptorB),
TEST_ASSERT_TRUE_MESSAGE(registryB.registerDynamicData("%PORTAL_APP_JS%", &appJsDescriptorB),
"Registry B app JS placeholder should register");
TemplateContext contextA;
+1 -1
View File
@@ -112,7 +112,7 @@ void test_multiple_parameters_stress() {
sprintf(id, "param%d", i);
sprintf(label, "Parameter %d", i);
params[i] = new WiFiManagerParameter(id, label, "default", 40);
wm.addParameter(params[i]);
wm.portalAddParameter(params[i]);
}
TEST_ASSERT_EQUAL(20, wm.getParametersCount());
@@ -2,6 +2,7 @@
#include <Arduino.h>
#include <TemplateEngine.h>
#include "templates/RootShell.h"
#include "templates/PortalAppJS.h"
#include "../test_main.h"
namespace {
@@ -27,6 +28,22 @@ const char kEmpty[] PROGMEM = "";
const char kDocTitle[] PROGMEM = "Config ESP";
const char kBootstrapJson[] PROGMEM = "{\"title\":\"Test\"}";
const char kPortalAppJs[] PROGMEM = "console.log('portal');";
const char kPortalAppendJs[] PROGMEM = "";
const char* dynamicStringGetter(void* userData) {
const auto* value = static_cast<const String*>(userData);
return value ? value->c_str() : "";
}
size_t dynamicStringLengthGetter(const char* data, void* /*userData*/) {
return data ? strlen(data) : 0;
}
void configureDescriptor(DynamicDataDescriptor& descriptor, String& value) {
descriptor.getter = &dynamicStringGetter;
descriptor.getLength = &dynamicStringLengthGetter;
descriptor.userData = &value;
}
} // namespace
@@ -38,6 +55,7 @@ void test_shell_template_renders_core_placeholders() {
registry.registerProgmemData("%PAGE_TITLE%", kDocTitle);
registry.registerProgmemData("%BOOTSTRAP_JSON%", kBootstrapJson);
registry.registerProgmemData("%PORTAL_APP_JS%", kPortalAppJs);
registry.registerProgmemData("%PORTAL_APPEND_JS%", kPortalAppendJs);
String output = renderTemplate(WM_ROOT_SHELL_TEMPLATE, registry);
@@ -51,3 +69,36 @@ void test_shell_template_renders_core_placeholders() {
Serial.println("[TEST] Portal shell template rendering test completed successfully");
}
void test_shell_template_renders_dynamic_styles_with_percent_values() {
Serial.println("[TEST] Testing portal shell dynamic styles placeholder with percent values...");
PlaceholderRegistry registry(8);
String styles = F("<style>body{width:100%;max-width:92%}</style>");
String title = F("Config ESP");
String bootstrap = F("{\"title\":\"Test\"}");
String appendJs = F("");
DynamicDataDescriptor stylesDescriptor{};
DynamicDataDescriptor titleDescriptor{};
DynamicDataDescriptor bootstrapDescriptor{};
DynamicDataDescriptor appendJsDescriptor{};
configureDescriptor(stylesDescriptor, styles);
configureDescriptor(titleDescriptor, title);
configureDescriptor(bootstrapDescriptor, bootstrap);
configureDescriptor(appendJsDescriptor, appendJs);
TEST_ASSERT_TRUE(registry.registerDynamicData("%STYLES%", &stylesDescriptor));
TEST_ASSERT_TRUE(registry.registerDynamicData("%PAGE_TITLE%", &titleDescriptor));
TEST_ASSERT_TRUE(registry.registerDynamicData("%BOOTSTRAP_JSON%", &bootstrapDescriptor));
TEST_ASSERT_TRUE(registry.registerProgmemData("%PORTAL_APP_JS%", kPortalAppJs));
TEST_ASSERT_TRUE(registry.registerDynamicData("%PORTAL_APPEND_JS%", &appendJsDescriptor));
String output = renderTemplate(WM_ROOT_SHELL_TEMPLATE, registry);
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("<style>body{width:100%;max-width:92%}</style>"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("<title>Config ESP</title>"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("{\"title\":\"Test\"}"));
Serial.println("[TEST] Dynamic styles placeholder percent rendering test completed successfully");
}
@@ -57,7 +57,7 @@ void test_scan_status_checking() {
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");
TEST_IGNORE_MESSAGE("UNIT_TEST helpers unavailable");
#endif
Serial.println("[TEST] Cached scan snapshot state test completed successfully");
@@ -79,7 +79,7 @@ void test_scan_completion_wait() {
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");
TEST_IGNORE_MESSAGE("UNIT_TEST helpers unavailable");
#endif
Serial.println("[TEST] Scan timeout transition test completed successfully");
@@ -101,7 +101,7 @@ void test_scan_cancels_when_connect_pending() {
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");
TEST_IGNORE_MESSAGE("UNIT_TEST helpers unavailable");
#endif
Serial.println("[TEST] Scan cancellation during connect test completed successfully");
@@ -122,7 +122,7 @@ void test_scan_cancels_when_lifecycle_blocked() {
TEST_ASSERT_FALSE_MESSAGE(wm.getScanSnapshot().schedulePending,
"Lifecycle blocking should leave scan engine idle");
#else
TEST_ASSERT_TRUE_MESSAGE(true, "UNIT_TEST helpers unavailable");
TEST_IGNORE_MESSAGE("UNIT_TEST helpers unavailable");
#endif
Serial.println("[TEST] Scan cancellation during lifecycle block test completed successfully");
@@ -144,7 +144,7 @@ void test_scan_generation_invalidated_on_reset() {
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");
TEST_IGNORE_MESSAGE("UNIT_TEST helpers unavailable");
#endif
Serial.println("[TEST] Scan generation invalidation on reset test completed successfully");