Release v3.1.0

This commit is contained in:
2026-09-01 10:59:25 +10:00
parent 25977a7320
commit c529fc15a8
30 changed files with 628 additions and 248 deletions
+2
View File
@@ -4,6 +4,8 @@ on:
push:
branches:
- device-framework
tags:
- "v*"
pull_request:
concurrency:
+2
View File
@@ -3,6 +3,8 @@
## 3.1.0
- Add an opt-in primary/fallback station-profile controller with bounded failover, reconnection, a durable consumer-supplied store, and profile-aware portal APIs. DeviceFramework uses this to persist verified WiFi profiles transactionally.
- Add portable portal branding and presentation hooks, including theme-aware shell and template rendering, without requiring DeviceFramework.
- Pin ESP32 tests to the Arduino 3-compatible pioarduino platform release and resolve DFTE 1.1.0.
## 3.0.6
+29 -2
View File
@@ -6,7 +6,7 @@ WiFiManager is the maintained ESP8266/ESP32 configuration-portal fork used by De
- **Single-shell portal:** one responsive SPA for WiFi, parameters, information, actions, and firmware update flow.
- **Data-first APIs:** portal state and actions are exposed under `/api/...`, not scraped from HTML.
- **Controlled customisation:** supported `portal*` APIs customise branding, parameters, cards, layout, and small style/JS enhancements without exposing portal internals.
- **Controlled portal UI:** semantic identity/theme values and structured portal APIs without exposing portal internals.
- **ESP8266 and ESP32:** clean PlatformIO consumers resolve the right asynchronous TCP transport automatically.
## Try it
@@ -30,11 +30,38 @@ void loop() {}
For a DeviceFramework device, configure the framework’s shared device password instead. DeviceFramework applies it consistently to the provisioning AP, OTA, HTTP Basic authentication, and WebSerial.
### Brand it
```cpp
const char kTitle[] PROGMEM = "Set up Temperature Monitor";
const char kBrand[] PROGMEM = "Tree";
const char kAccent[] PROGMEM = "#347a45";
const WiFiManagerPortalConfig kPortalUI = {
WiFiManagerPortalText::progmem(kTitle),
WiFiManagerPortalText::progmem(kBrand),
{}, {}, {},
{
{}, {}, {}, {}, {},
WiFiManagerPortalText::progmem(kAccent),
{}, {}, {}, {}, {},
10, 0,
},
};
void setup() {
wifi.setPortalConfig(kPortalUI); // before the portal starts
wifi.autoConnect("Device Setup");
}
```
The full standalone example, including a static SVG, is [BrandedPortal](examples/BrandedPortal/BrandedPortal.ino).
## Explore the portal
| Goal | Guide |
| --- | --- |
| Brand the portal, add parameters, cards, or small UI enhancements | [Portal customisation](docs/PORTAL_CUSTOMIZATION.md) |
| Brand the portal or add structured built-in content | [Portal UI](docs/PORTAL_UI.md) |
| Configure primary/fallback station profiles | [Station profiles](docs/STATION_PROFILES.md) |
| Understand the JSON APIs and station-connect handoff | [Portal API](docs/PORTAL_API.md) |
| Build a clean consumer or work on this fork | [Testing](docs/TESTING.md) · [Development](docs/DEVELOPMENT.md) |
+7
View File
@@ -7,6 +7,13 @@ lib_deps =
WiFiManager=symlink:///path/to/WiFiManager
```
The ESP32 environments pin the PlatformIO-compatible pioarduino 51.03.05
platform package, which packages official Arduino-ESP32 3.0.5. This avoids the
known six-second asynchronous scan failure in the older 2.0.17 framework. Core
3 also requires the `SOC_WIFI_SUPPORTED`, `Network/src`, and ESP8266-transport
ignore settings shown in this repository `platformio.ini`; keep those settings
when adding an ESP32 environment.
Before a release, update `library.json`, `CHANGELOG.md`, and the relevant public documentation, then run:
```bash
+1 -1
View File
@@ -36,6 +36,6 @@ The package manifest resolves DFTE, ESPAsyncWebServer, and the correct TCP trans
DeviceFramework creates and configures WiFiManager for its normal lifecycle. Use DeviceFramework’s persistent shared device-password API instead of separately configuring an AP, OTA, HTTP, and WebSerial password.
Next: [portal customisation](PORTAL_CUSTOMIZATION.md) or [portal API](PORTAL_API.md).
Next: [portal UI](PORTAL_UI.md) or [portal API](PORTAL_API.md).
Back to [documentation](README.md) · [project overview](../README.md).
-65
View File
@@ -1,65 +0,0 @@
# Portal customisation
The stable extension surface is the `portal*` API and JSON API contract. Templates, undocumented DOM IDs, and SPA routing internals are not consumer extension points.
## Supported changes
- `portalSetBrand*` and `portalSetContextIdentityText()` for the visible identity.
- `portalSetPage*`, `portalSetAction*`, `portalSetLayout*`, and `portalSetBehavior*` for built-in capability and layout choices.
- `portalAddParameter()`, including raw HTML blocks inside parameter-rendering surfaces.
- `portalAddInfoSection()` and `portalAddHomeCard()` for structured content.
- `portalAppendCss()`, `portalOverrideCss()`, and `portalAppendJs()` for small presentation enhancements.
```cpp
WiFiManager wm;
wm.portalSetBrandTitle("Solar Battery Monitor Setup");
wm.portalSetContextIdentityText("Solar Battery Monitor");
wm.portalSetBrandHomeIntro(
"Connect the monitor to WiFi, then review its battery settings."
);
wm.portalSetPageUpdateVisible(false);
wm.portalSetActionEraseVisible(false);
wm.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage);
WiFiManagerParameter mqttHost(
"mqtt_host", "MQTT host", "broker.local", 64,
"placeholder='broker.local'"
);
wm.portalAddParameter(&mqttHost);
PortalHomeCard summary;
summary.id = "battery";
summary.title = "Battery";
summary.kind = PortalHomeCardKind::KeyValue;
summary.items.push_back({"voltage", "Voltage", "13.2V"});
wm.portalAddHomeCard(summary);
wm.portalAppendJs(
"document.addEventListener('wm:ready', function(event) {"
" console.log('Portal ready', event.detail.boot);"
"});"
);
```
## Enhancement events
Appended JavaScript may enhance the existing SPA through:
- `wm:ready`, with `event.detail.boot`;
- `wm:view-changed`, with `event.detail.route`.
It should not replace routing or built-in action flow.
## Intentional boundary
The following are fork-level changes, not supported consumer customisation:
- arbitrary home, information, navigation, or shell HTML injection;
- replacing built-in SPA routes or action flows;
- depending on undocumented DOM IDs or private templates;
- adding a new backend-to-frontend workflow without an API contract.
For data and endpoint behaviour, see [Portal API](PORTAL_API.md).
Back to [documentation](README.md) · [project overview](../README.md).
+80
View File
@@ -0,0 +1,80 @@
# Portal UI
`WiFiManagerPortalConfig` is the supported presentation API for the built-in provisioning portal. It changes identity and semantic visual tokens only. WiFiManager continues to own portal routes, forms, navigation, captive behavior, reset, and OTA views.
Apply the configuration before `autoConnect()`, `startConfigPortal()`, or `startWebPortal()`. The configuration is non-owning: text and SVG data must have static firmware lifetime and declare whether it is in RAM or PROGMEM. WiFiManager locks presentation after a portal starts, so active asynchronous responses cannot observe a partial configuration.
## Standalone branded portal
```cpp
#include <Arduino.h>
#include <WiFiManager.h>
WiFiManager wifi;
namespace {
const char kTitle[] PROGMEM = "Set up Temperature Monitor";
const char kIdentity[] PROGMEM = "Tree";
const char kIntro[] PROGMEM = "Connect this device to Wi-Fi.";
const char kLogoAlt[] PROGMEM = "Tree";
const char kLogo[] PROGMEM = R"svg(<svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="28"/></svg>)svg";
const char kPage[] PROGMEM = "#f4f7f3";
const char kSurface[] PROGMEM = "#ffffff";
const char kAccent[] PROGMEM = "#347a45";
const char kAccentText[] PROGMEM = "#ffffff";
const WiFiManagerPortalConfig kPortalUI = {
WiFiManagerPortalText::progmem(kTitle),
WiFiManagerPortalText::progmem(kIdentity),
WiFiManagerPortalText::progmem(kIntro),
WiFiManagerPortalAsset::svgFromProgmem(kLogo),
WiFiManagerPortalText::progmem(kLogoAlt),
{
WiFiManagerPortalText::progmem(kPage),
WiFiManagerPortalText::progmem(kSurface),
{}, {}, {},
WiFiManagerPortalText::progmem(kAccent),
{},
WiFiManagerPortalText::progmem(kAccentText),
{}, {}, {},
10, 6,
},
};
}
void setup() {
wifi.setPortalConfig(kPortalUI);
wifi.autoConnect("Temperature Monitor");
}
void loop() {}
```
The complete buildable example is [BrandedPortal](../examples/BrandedPortal/BrandedPortal.ino). The compile fixture exercises this API on ESP8266 and ESP32.
## Configuration reference
Leave a text or colour value empty, or a radius at `0`, to retain the built-in stylesheet value. The default title is `WiFiManager`.
| Field | Used by |
| --- | --- |
| `title` | Document title and portal heading |
| `identityText` | Product/device line below the heading |
| `homeIntro` | Introductory text on the home view |
| `logo.svg`, `logoAltText` | Optional trusted inline SVG and its accessible label |
| `pageBackground`, `surface`, `text`, `mutedText`, `border` | Portal surfaces and text |
| `accent`, `accentHover`, `accentText` | Primary links and actions |
| `success`, `danger`, `dangerHover` | Status and destructive actions |
| `cornerRadiusPx`, `smallCornerRadiusPx` | Card and compact-control corners, limited to 64 px |
Theme values accept only simple semantic CSS value syntax and are emitted once into a small `#wm-portal-theme` block. This is deliberately not a raw CSS or JavaScript injection API. An SVG is a trusted compiled firmware asset, never form, MQTT, or network input.
## Built-in portal capabilities
Presentation uses one configuration route: `setPortalConfig()`. Existing structured portal capabilities remain separate: `portalAddParameter()`, `portalAddInfoSection()`, `portalAddHomeCard()`, page visibility, and portal behavior configure documented built-in functionality rather than private markup. See [Portal API](PORTAL_API.md) and [Station profiles](STATION_PROFILES.md).
There is no arbitrary HTML shell, route replacement, navigation injection, raw stylesheet, or script hook. If a product needs a new portal capability, add a narrow WiFiManager contract and test it on both supported targets.
For a DeviceFramework device, use `DeviceFrameworkUIConfig`; DeviceFramework maps its product-level configuration into this portal API. See [DeviceFramework web UI](https://github.com/alexhopeoconnor/DeviceFramework/blob/main/docs/WEB_UI.md).
Back to the [documentation index](README.md) or [project overview](../README.md).
+2 -2
View File
@@ -5,7 +5,7 @@ This maintained fork intentionally has a narrower, explicit customisation bounda
| I want to… | Read |
| --- | --- |
| Start a basic portal or install a released dependency | [Getting started](GETTING_STARTED.md) |
| Brand the SPA, add parameters/cards, or use the supported hooks | [Portal customisation](PORTAL_CUSTOMIZATION.md) |
| Brand the portal or add structured built-in content | [Portal UI](PORTAL_UI.md) |
| Configure primary/fallback station profiles or their portal workflow | [Station profiles](STATION_PROFILES.md) |
| Consume JSON endpoints or station-connect status | [Portal API](PORTAL_API.md) |
| Build a clean consumer | [Testing](TESTING.md) |
@@ -13,7 +13,7 @@ This maintained fork intentionally has a narrower, explicit customisation bounda
## Documentation rules
- Portal customisation owns the supported `portal*` surface.
- Portal UI owns branding, semantic theming, and the supported `portal*` surface.
- Portal API owns HTTP response contracts.
- The root README is a short onboarding page, not an API reference.
+3 -1
View File
@@ -1,6 +1,8 @@
# Testing
The clean-consumer check builds a project that declares only WiFiManager. It proves the package manifest resolves DFTE, ESPAsyncWebServer, and the correct ESP8266 or ESP32 TCP dependency without a sibling checkout or attached board.
The clean-consumer check builds a project that declares only WiFiManager. It proves the package manifest resolves DFTE, ESPAsyncWebServer, and the correct ESP8266 or ESP32 TCP dependency without a sibling checkout or attached board. The runner removes a previous local package link before each
check, so dependency resolution uses the current manifest rather than a stale
`.pio` copy.
```bash
./scripts/test.sh compile --platform esp8266
+50
View File
@@ -0,0 +1,50 @@
#include <Arduino.h>
#include <WiFiManager.h>
WiFiManager wifi;
namespace {
const char kPortalTitle[] PROGMEM = "Set up Temperature Monitor";
const char kPortalIdentity[] PROGMEM = "Tree";
const char kPortalIntro[] PROGMEM = "Connect this device to Wi-Fi.";
const char kPortalLogoAlt[] PROGMEM = "Tree";
const char kTreeLogo[] PROGMEM = "<svg viewBox='0 0 64 64' aria-hidden='true'><circle cx='32' cy='32' r='28' fill='#347a45'/></svg>";
const char kPage[] PROGMEM = "#f4f7f3";
const char kSurface[] PROGMEM = "#ffffff";
const char kText[] PROGMEM = "#1c251e";
const char kAccent[] PROGMEM = "#347a45";
const char kAccentText[] PROGMEM = "#ffffff";
const WiFiManagerPortalConfig kPortalUI = {
WiFiManagerPortalText::progmem(kPortalTitle),
WiFiManagerPortalText::progmem(kPortalIdentity),
WiFiManagerPortalText::progmem(kPortalIntro),
WiFiManagerPortalAsset::svgFromProgmem(kTreeLogo),
WiFiManagerPortalText::progmem(kPortalLogoAlt),
{
WiFiManagerPortalText::progmem(kPage),
WiFiManagerPortalText::progmem(kSurface),
WiFiManagerPortalText::progmem(kText),
{}, {},
WiFiManagerPortalText::progmem(kAccent),
{},
WiFiManagerPortalText::progmem(kAccentText),
{}, {}, {},
10, 6,
},
};
}
void setup() {
Serial.begin(115200);
if (!wifi.setPortalConfig(kPortalUI)) {
Serial.println("Portal UI configuration was rejected");
}
wifi.setConfigPortalTimeout(180);
if (!wifi.autoConnect("Temperature Monitor")) {
ESP.restart();
}
}
void loop() {}
+17 -21
View File
@@ -142,6 +142,7 @@
#include <string>
// Include utility functions
#include "WiFiManagerUtils.h"
#include "WiFiManagerPortalUI.h"
// A station profile is intentionally fixed-size. ESP Wi-Fi accepts one station
// configuration at a time, so multi-network behaviour belongs to the
@@ -265,10 +266,11 @@ struct PortalHomeCard {
};
struct PortalBrandState {
String title = "WiFiManager";
String identityTextOverride;
String homeIntro;
String logoSvg;
WiFiManagerPortalText title;
WiFiManagerPortalText identityTextOverride;
WiFiManagerPortalText homeIntro;
WiFiManagerPortalAsset logo;
WiFiManagerPortalText logoAltText;
};
struct PortalPageState {
@@ -290,12 +292,6 @@ struct PortalLayoutState {
bool paramsOnWifiPage = true;
};
struct PortalAssetState {
String appendedCss;
String overriddenCss;
String appendedJs;
};
struct PortalStructuredExtrasState {
std::vector<PortalInfoSection> infoSections;
std::vector<PortalHomeCard> homeCards;
@@ -594,11 +590,10 @@ class WiFiManager
// clean connect, always disconnect before connecting
void setCleanConnect(bool enable); // default false
// ---- 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);
// ---- Portal presentation ----
// Apply this before a portal is started. The portal response model is
// immutable while active so async responses never observe partial UI state.
bool setPortalConfig(const WiFiManagerPortalConfig& config);
void portalSetPageInfoVisible(bool visible);
void portalSetPageUpdateVisible(bool visible);
@@ -631,10 +626,6 @@ class WiFiManager
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();
@@ -890,14 +881,19 @@ class WiFiManager
boolean _disableConfigPortal = true; // FOR autoconnect - stop config portal if cp wifi save
String _hostname = ""; // hostname for esp8266 for dhcp, and or MDNS
// Grouped portal presentation / customization (see portal* setters; JSON in v2 bootstrap)
// Grouped portal presentation / customization (see portal APIs and JSON bootstrap)
PortalBrandState _portalBrand;
WiFiManagerPortalTheme _portalTheme;
String _portalThemeStyle;
PortalPageState _portalPages;
PortalActionState _portalActions;
PortalLayoutState _portalLayout;
PortalAssetState _portalAssets;
PortalStructuredExtrasState _portalStructured;
bool canChangePortalPresentation() const;
bool isPortalThemeValid(const WiFiManagerPortalTheme& theme) const;
void rebuildPortalThemeStyle();
// internal options
// wifiscan notes
@@ -106,7 +106,6 @@ 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);
@@ -0,0 +1,93 @@
/**
* WiFiManagerPortalUI.h
*
* Deliberate, portal-specific presentation configuration. This header does
* not depend on DeviceFramework: WiFiManager remains useful on its own.
*/
#ifndef WIFI_MANAGER_PORTAL_UI_H
#define WIFI_MANAGER_PORTAL_UI_H
#include <Arduino.h>
enum class WiFiManagerPortalStorage : uint8_t {
Ram,
Progmem,
};
/** Non-owning static text. The caller retains the data for the firmware lifetime. */
struct WiFiManagerPortalText {
const char* data = nullptr;
WiFiManagerPortalStorage storage = WiFiManagerPortalStorage::Ram;
static constexpr WiFiManagerPortalText ram(const char* value) {
return {value, WiFiManagerPortalStorage::Ram};
}
static constexpr WiFiManagerPortalText progmem(const char* value) {
return {value, WiFiManagerPortalStorage::Progmem};
}
bool empty() const { return data == nullptr || length() == 0; }
size_t length() const {
return data == nullptr ? 0 : (storage == WiFiManagerPortalStorage::Progmem ? strlen_P(data) : strlen(data));
}
char at(size_t index) const {
return storage == WiFiManagerPortalStorage::Progmem
? static_cast<char>(pgm_read_byte(data + index))
: data[index];
}
};
/** Optional inline SVG branding asset. It is not a general HTML extension point. */
struct WiFiManagerPortalAsset {
WiFiManagerPortalText svg;
static constexpr WiFiManagerPortalAsset svgFromRam(const char* value) {
return {WiFiManagerPortalText::ram(value)};
}
static constexpr WiFiManagerPortalAsset svgFromProgmem(const char* value) {
return {WiFiManagerPortalText::progmem(value)};
}
bool empty() const { return svg.empty(); }
};
/**
* Semantic colour and shape values for WiFiManager's built-in portal.
*
* Leave a value empty to retain the built-in stylesheet value. Values are
* validated before they are emitted into the portal stylesheet; they are not
* a general CSS injection mechanism.
*/
struct WiFiManagerPortalTheme {
WiFiManagerPortalText pageBackground;
WiFiManagerPortalText surface;
WiFiManagerPortalText text;
WiFiManagerPortalText mutedText;
WiFiManagerPortalText border;
WiFiManagerPortalText accent;
WiFiManagerPortalText accentHover;
WiFiManagerPortalText accentText;
WiFiManagerPortalText danger;
WiFiManagerPortalText dangerHover;
WiFiManagerPortalText success;
uint8_t cornerRadiusPx = 0;
uint8_t smallCornerRadiusPx = 0;
};
/**
* Complete setup-time presentation configuration for WiFiManager's portal.
*
* Text and SVG assets are non-owning static data in RAM or PROGMEM. Apply this
* before starting a portal; asynchronous portal responses use immutable state.
*/
struct WiFiManagerPortalConfig {
WiFiManagerPortalText title;
WiFiManagerPortalText identityText;
WiFiManagerPortalText homeIntro;
WiFiManagerPortalAsset logo;
WiFiManagerPortalText logoAltText;
WiFiManagerPortalTheme theme;
};
#endif // WIFI_MANAGER_PORTAL_UI_H
+5 -4
View File
@@ -23,6 +23,7 @@ const char CSS_STYLE[] PROGMEM = "<style>"
"--wm-border:#e2e8f0;"
"--wm-brand:#0ea5e9;"
"--wm-brand-hover:#0284c7;"
"--wm-brand-text:#ffffff;"
"--wm-danger:#dc2626;"
"--wm-danger-hover:#b91c1c;"
"--wm-success:#16a34a;"
@@ -128,16 +129,16 @@ const char CSS_STYLE[] PROGMEM = "<style>"
"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{background:var(--wm-brand);color:var(--wm-brand-text)}"
".wm-btn--primary:hover{background:var(--wm-brand-hover)}"
".wm-btn--secondary{background:var(--wm-bg);color:var(--wm-text);border:1px solid var(--wm-border)}"
".wm-btn--secondary:hover{border-color:var(--wm-brand);color:var(--wm-brand)}"
".wm-btn--danger{background:var(--wm-danger);color:#fff}"
".wm-btn--danger{background:var(--wm-danger);color:var(--wm-brand-text)}"
".wm-btn--danger:hover{background:var(--wm-danger-hover)}"
"input[type=file]{width:100%;padding:10px;border:1px dashed var(--wm-border);border-radius:var(--wm-radius-sm);"
"background:var(--wm-bg);font-size:.9rem}"
/* Legacy: unclassed submit in forms */
"button[type=submit],.wm-form-actions .wm-btn--primary{background:var(--wm-brand);color:#fff}"
"button[type=submit],.wm-form-actions .wm-btn--primary{background:var(--wm-brand);color:var(--wm-brand-text)}"
"button[type=submit]:hover{background:var(--wm-brand-hover)}"
/* Device actions / button stack */
".wm-actions-stack{display:flex;flex-direction:column;gap:10px;margin-top:4px}"
@@ -198,7 +199,7 @@ const char CSS_STYLE[] PROGMEM = "<style>"
".wm-dialog-msg{margin:0 0 16px;line-height:1.45;color:var(--wm-text)}"
".wm-dialog-actions{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap}"
".wm-dialog-actions .wm-btn{min-width:108px}"
".wm-dialog-ok,.wm-btn.wm-dialog-ok{background:var(--wm-brand)!important;color:#fff!important}"
".wm-dialog-ok,.wm-btn.wm-dialog-ok{background:var(--wm-brand)!important;color:var(--wm-brand-text)!important}"
".wm-dialog-ok:hover{background:var(--wm-brand-hover)!important}"
"body.invert .wm-toast.ok{background:rgba(22,163,74,.2)}"
"body.invert .wm-toast.err{background:rgba(220,38,38,.2)}"
+39 -16
View File
@@ -66,8 +66,13 @@ function bindPortalChromeEvents(){
}
function bindFormSubmitHandlers(){
var wf=$('wm-wifi-form'), pf=$('wm-param-form'), of=$('wm-ota-form');
if(wf && window.portalWifiSave) wf.addEventListener('submit', window.portalWifiSave);
var wf=document.querySelectorAll('.wm-wifi-data-form'), pf=$('wm-param-form'), of=$('wm-ota-form'), wa=document.querySelectorAll('[data-wm-wifi-action]'), i;
if(window.portalWifiSave){
for(i=0;i<wf.length;i++)wf[i].addEventListener('submit', window.portalWifiSave);
}
if(window.portalWifiAction){
for(i=0;i<wa.length;i++)wa[i].addEventListener('click', window.portalWifiAction);
}
if(pf && window.portalParamSave) pf.addEventListener('submit', window.portalParamSave);
if(of && window.portalOtaSubmit) of.addEventListener('submit', window.portalOtaSubmit);
}
@@ -315,7 +320,8 @@ function viewHome(){
var timeoutSeconds=ctx.portalTimeoutSecondsRemaining||0;
var html=navBar('home');
html+="<header class='wm-hero'>";
if(brand.logoSvg){html+="<div class='wm-brand-logo'>"+brand.logoSvg+"</div>";}
var logoAlt=esc(brand.logoAltText||'');
if(brand.logoSvg){html+="<div class='wm-brand-logo'"+(logoAlt?" role='img' aria-label='"+logoAlt+"'":"")+">"+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>";}
@@ -509,16 +515,16 @@ function renderStationProfileFields(profiles){
var hint=primary
? 'This network is tried first for a new connection.'
: 'Tried when the primary network is unavailable.';
html+="<div class='wm-card wm-station-profile'><h2>"+esc(title)+"</h2><p class='wm-help'>"+esc(hint)+"</p>";
html+="<form class='wm-card wm-station-profile wm-wifi-data-form' id='wm-wifi-profile-"+i+"'><h2>"+esc(title)+"</h2><p class='wm-help'>"+esc(hint)+"</p>";
html+="<label for='wm-s"+i+"'>SSID"+(primary?'':' (leave empty to disable)')+"</label>";
html+="<input id='wm-s"+i+"' name='s"+i+"' type='text' maxlength='32' value='"+esc(p.ssid||'')+"'/>";
html+="<input id='wm-s"+i+"' name='s"+i+"' type='text' autocomplete='username' maxlength='32' value='"+esc(p.ssid||'')+"'/>";
html+="<label for='wm-p"+i+"'>Password</label>";
html+="<input id='wm-p"+i+"' class='wm-profile-password' name='p"+i+"' type='password' maxlength='64' placeholder='"+(p.passwordSet?'Configured — enter a new password to replace it':'Leave blank for an open network')+"'/>";
html+="<input id='wm-p"+i+"' class='wm-profile-password' name='p"+i+"' type='password' autocomplete='current-password' maxlength='64' placeholder='"+(p.passwordSet?'Configured — enter a new password to replace it':'Leave blank for an open network')+"'/>";
html+="<div class='wm-checkbox-row'><input type='checkbox' id='wm-showpass"+i+"' class='wm-showpass' data-target='wm-p"+i+"'/> <label for='wm-showpass"+i+"'>Show password</label></div>";
if(p.passwordSet){
html+="<div class='wm-checkbox-row'><input type='checkbox' id='wm-clear"+i+"' name='clear"+i+"' value='1'/> <label for='wm-clear"+i+"'>Clear password / use an open network</label></div>";
}
html+='</div>';
html+='</form>';
}
return html;
}
@@ -533,10 +539,11 @@ function viewWifi(){
html+="<div id='wm-scan-results'></div>";
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'>";
if(Array.isArray(m.profiles) && m.profiles.length){
html+=renderStationProfileFields(m.profiles);
html+="<form class='wm-wifi-data-form' id='wm-wifi-form'>";
}else{
html+="<form class='wm-wifi-data-form' id='wm-wifi-form'>";
html+=renderFieldList(m.wifiFields||[]);
var wf=m.wifiFields||[];
var hasPass=false;
@@ -548,11 +555,12 @@ 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' name='stationAction' value='connect'>"+labelWithIcon('connect','Save and connect')+"</button>";
html+="</form>";
html+="<div class='wm-form-actions'><button type='button' class='wm-btn wm-btn--primary wm-btn--block' data-wm-wifi-action='connect'>"+labelWithIcon('connect','Save and connect')+"</button>";
if(Array.isArray(m.profiles) && m.profiles.length){
html+="<button type='submit' class='wm-btn wm-btn--secondary wm-btn--block' name='stationAction' value='save'>Save for later</button>";
html+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' data-wm-wifi-action='save'>Save for later</button>";
}
html+="</div></form>";
html+="</div>";
html+="<div id='wm-wifi-msg'></div></div>";
setView(html);
bindWifiViewEvents();
@@ -635,12 +643,17 @@ function pollWifiConnectStatus(){
poll();
}
window.portalWifiSave=function(ev){
ev.preventDefault();
var fd=new FormData(document.getElementById('wm-wifi-form'));
if(ev.submitter && ev.submitter.name){
fd.append(ev.submitter.name,ev.submitter.value||'');
function collectWifiFormData(){
var fd=new FormData(), forms=document.querySelectorAll('.wm-wifi-data-form'), i;
for(i=0;i<forms.length;i++){
new FormData(forms[i]).forEach(function(value,key){fd.append(key,value);});
}
return fd;
}
function submitWifi(action){
var fd=collectWifiFormData();
if(action)fd.append('stationAction',action);
var msg=$('wm-wifi-msg');
if(msg)msg.innerHTML='Submitting...';
api('/api/wifi/save',{method:'POST',body:fd}).then(function(res){
@@ -655,6 +668,16 @@ window.portalWifiSave=function(ev){
}
});
return false;
}
window.portalWifiSave=function(ev){
ev.preventDefault();
return submitWifi(ev.submitter&&ev.submitter.value?ev.submitter.value:'connect');
};
window.portalWifiAction=function(ev){
ev.preventDefault();
return submitWifi(ev.currentTarget.getAttribute('data-wm-wifi-action'));
};
// --- Info view ---
@@ -7,7 +7,7 @@
* 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%, %PORTAL_APPEND_JS%) are filled
* Placeholder keys (%PAGE_TITLE%, %STYLES%, %PORTAL_THEME%, %BOOTSTRAP_JSON%, %PORTAL_APP_JS%) are filled
* per request in WiFiManagerHandlers::handleRoot; do not treat placeholders as a customization API.
*/
@@ -25,6 +25,7 @@ const char WM_ROOT_SHELL_TEMPLATE[] PROGMEM =
"<meta name='viewport' content='width=device-width,initial-scale=1,user-scalable=no'/>"
"<title>%PAGE_TITLE%</title>"
"%STYLES%"
"%PORTAL_THEME%"
"</head>"
"<body class='portal'>"
"<div id='wm-toast' class='wm-toast' aria-live='polite' role='status' style='display:none'></div>"
@@ -41,9 +42,6 @@ const char WM_ROOT_SHELL_TEMPLATE[] PROGMEM =
"<script>"
"%PORTAL_APP_JS%"
"</script>"
"<script>"
"%PORTAL_APPEND_JS%"
"</script>"
"</body>"
"</html>";
+87 -29
View File
@@ -56,6 +56,25 @@ bool normalizeHostname(String& hostname) {
return true;
}
const char kDefaultPortalTitle[] PROGMEM = "WiFiManager";
bool isSafePortalText(const WiFiManagerPortalText& value) {
for (size_t i = 0; i < value.length(); ++i) {
const char c = value.at(i);
if (!(isAlphaNumeric(c) || c == '#' || c == '(' || c == ')' || c == ',' ||
c == '.' || c == '%' || c == ' ' || c == '-' || c == '/')) {
return false;
}
}
return true;
}
void appendPortalText(String& destination, const WiFiManagerPortalText& value) {
for (size_t i = 0; i < value.length(); ++i) {
destination += value.at(i);
}
}
} // namespace
/**
@@ -149,6 +168,7 @@ WiFiManager::WiFiManager() {
}
void WiFiManager::WiFiManagerInit(){
_portalBrand.title = WiFiManagerPortalText::progmem(kDefaultPortalTitle);
if (_logEnabled && (uint8_t)_runtimeMaxLevel >= (uint8_t)WiFiManagerLogLevel::Trace) {
debugPlatformInfo();
}
@@ -848,16 +868,6 @@ bool WiFiManager::startAP(){
log(WiFiManagerLogLevel::Info, kWiFiMgrLogSubsystem, F("StartAP with SSID: "),_apName);
#endif
#ifdef ESP8266
if(!WiFi.enableAP(true)) {
#ifndef WM_NO_LOG
log(WiFiManagerLogLevel::Error, kWiFiMgrLogSubsystem,F("[ERROR] enableAP failed!"));
#endif
return false;
}
delay(500); // workaround delay
#endif
// setup optional soft AP static ip config
if (_ap_static_ip) {
#ifndef WM_NO_LOG
@@ -2559,20 +2569,78 @@ void WiFiManager::setWiFiAPHidden(bool hidden){
}
void WiFiManager::portalSetBrandTitle(const String& title) {
_portalBrand.title = title;
bool WiFiManager::canChangePortalPresentation() const {
return !configPortalActive && !webPortalActive;
}
void WiFiManager::portalSetContextIdentityText(const String& identityText) {
_portalBrand.identityTextOverride = identityText;
bool WiFiManager::isPortalThemeValid(const WiFiManagerPortalTheme& theme) const {
return isSafePortalText(theme.pageBackground) && isSafePortalText(theme.surface) &&
isSafePortalText(theme.text) && isSafePortalText(theme.mutedText) &&
isSafePortalText(theme.border) && isSafePortalText(theme.accent) &&
isSafePortalText(theme.accentHover) && isSafePortalText(theme.accentText) &&
isSafePortalText(theme.danger) && isSafePortalText(theme.dangerHover) &&
isSafePortalText(theme.success) && theme.cornerRadiusPx <= 64 &&
theme.smallCornerRadiusPx <= 64;
}
void WiFiManager::portalSetBrandHomeIntro(const String& text) {
_portalBrand.homeIntro = text;
void WiFiManager::rebuildPortalThemeStyle() {
_portalThemeStyle = "";
const bool hasColours = !_portalTheme.pageBackground.empty() || !_portalTheme.surface.empty() ||
!_portalTheme.text.empty() || !_portalTheme.mutedText.empty() || !_portalTheme.border.empty() ||
!_portalTheme.accent.empty() || !_portalTheme.accentHover.empty() ||
!_portalTheme.accentText.empty() || !_portalTheme.danger.empty() ||
!_portalTheme.dangerHover.empty() || !_portalTheme.success.empty();
if (!hasColours && _portalTheme.cornerRadiusPx == 0 && _portalTheme.smallCornerRadiusPx == 0) {
return;
}
const auto appendToken = [this](const char* name, const WiFiManagerPortalText& value) {
if (value.empty()) return;
_portalThemeStyle += name;
_portalThemeStyle += ':';
appendPortalText(_portalThemeStyle, value);
_portalThemeStyle += ';';
};
_portalThemeStyle = F("<style id='wm-portal-theme'>:root{");
appendToken("--wm-bg", _portalTheme.pageBackground);
appendToken("--wm-surface", _portalTheme.surface);
appendToken("--wm-text", _portalTheme.text);
appendToken("--wm-muted", _portalTheme.mutedText);
appendToken("--wm-border", _portalTheme.border);
appendToken("--wm-brand", _portalTheme.accent);
appendToken("--wm-brand-hover", _portalTheme.accentHover);
appendToken("--wm-brand-text", _portalTheme.accentText);
appendToken("--wm-danger", _portalTheme.danger);
appendToken("--wm-danger-hover", _portalTheme.dangerHover);
appendToken("--wm-success", _portalTheme.success);
if (_portalTheme.cornerRadiusPx > 0) {
_portalThemeStyle += F("--wm-radius:");
_portalThemeStyle += String(_portalTheme.cornerRadiusPx);
_portalThemeStyle += F("px;");
}
if (_portalTheme.smallCornerRadiusPx > 0) {
_portalThemeStyle += F("--wm-radius-sm:");
_portalThemeStyle += String(_portalTheme.smallCornerRadiusPx);
_portalThemeStyle += F("px;");
}
_portalThemeStyle += F("}</style>");
}
void WiFiManager::portalSetBrandLogoSvg(const String& svgMarkup) {
_portalBrand.logoSvg = svgMarkup;
bool WiFiManager::setPortalConfig(const WiFiManagerPortalConfig& config) {
if (!canChangePortalPresentation() || !isPortalThemeValid(config.theme)) {
return false;
}
_portalBrand.title = config.title.empty()
? WiFiManagerPortalText::progmem(kDefaultPortalTitle)
: config.title;
_portalBrand.identityTextOverride = config.identityText;
_portalBrand.homeIntro = config.homeIntro;
_portalBrand.logo = config.logo;
_portalBrand.logoAltText = config.logoAltText;
_portalTheme = config.theme;
rebuildPortalThemeStyle();
return true;
}
void WiFiManager::portalSetPageInfoVisible(bool visible) {
@@ -2587,6 +2655,7 @@ void WiFiManager::portalSetPageSetupVisible(bool visible) {
_portalPages.setupVisible = visible;
}
void WiFiManager::portalSetActionEraseVisible(bool visible) {
_portalActions.eraseVisible = visible;
}
@@ -2691,17 +2760,6 @@ 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;
}
/**
* check if the config portal is running
+61 -67
View File
@@ -37,8 +37,25 @@ static void jsonAppendEscaped(String& out, const String& s) {
}
}
static void jsonAppendEscaped(String& out, const WiFiManagerPortalText& text) {
for (size_t i = 0; i < text.length(); ++i) {
const char c = text.at(i);
if (c == '"') {
out += F("\\\"");
} else if (c == '\\') {
out += F("\\\\");
} else if (c == '\n') {
out += F("\\n");
} else if (c != '\r') {
out += c;
}
}
}
namespace {
const char kEmptyPortalPlaceholder[] PROGMEM = "";
inline void reservePage(String& page, size_t extraBytes = WM_PAGE_RESERVE_BYTES) {
if (extraBytes == 0) return;
const size_t targetLen = page.length() + extraBytes;
@@ -60,24 +77,26 @@ void configureDynamicStringDescriptor(DynamicDataDescriptor& descriptor, String&
descriptor.userData = &value;
}
String readProgmemString(const char* p) {
return String(FPSTR(p));
const char* portalTextGetter(void* userData) {
const auto* text = static_cast<const WiFiManagerPortalText*>(userData);
return text && text->storage == WiFiManagerPortalStorage::Ram && text->data
? text->data
: "";
}
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;
size_t portalTextLengthGetter(const char* /*data*/, void* userData) {
const auto* text = static_cast<const WiFiManagerPortalText*>(userData);
return text ? text->length() : 0;
}
void configurePortalTextDescriptor(DynamicDataDescriptor& descriptor,
const WiFiManagerPortalText& text) {
descriptor.getter = &portalTextGetter;
descriptor.getLength = &portalTextLengthGetter;
descriptor.userData = const_cast<WiFiManagerPortalText*>(&text);
}
const char* portalHomeCardKindJson(PortalHomeCardKind k) {
switch (k) {
case PortalHomeCardKind::Text:
@@ -110,31 +129,27 @@ AsyncWebServerResponse* beginTemplateResponse(AsyncWebServerRequest* request,
);
}
// Shell contract (must match templates/RootShell.h — customize UI via WiFiManager portal* APIs + bootstrap JSON, not registries):
// Shell contract (must match templates/RootShell.h):
// %PAGE_TITLE% -> document title (from WiFiManager title state)
// %STYLES% -> portal CSS (embedded default + portalAppendCss / portalOverrideCss)
// %STYLES% -> immutable built-in portal stylesheet in PROGMEM
// %PORTAL_THEME% -> optional validated semantic-token override
// %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;
WiFiManagerPortalText pageTitle;
PlaceholderRegistry registry;
TemplateContext context;
DynamicDataDescriptor bootstrapDescriptor;
DynamicDataDescriptor pageTitleDescriptor;
DynamicDataDescriptor stylesDescriptor;
DynamicDataDescriptor appendJsDescriptor;
DynamicDataDescriptor themeDescriptor;
PortalShellRenderBundle()
: registry(WM_TEMPLATE_REGISTRY_CAPACITY),
bootstrapDescriptor{},
pageTitleDescriptor{},
stylesDescriptor{},
appendJsDescriptor{} {}
themeDescriptor{} {}
};
// Custom HTML attribute strings (e.g. from DeviceFramework generateCustomHTML) may include
@@ -150,24 +165,6 @@ bool portalCustomAttrsIndicatePassword(const String& customAttrs) {
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;
@@ -199,7 +196,6 @@ void WiFiManagerHandlers::appendPortalExtraInfoSectionsJson(String& json, bool&
json += F("]}");
}
}
void WiFiManagerHandlers::appendPortalExtraHomeCardsJson(String& json, bool& first) {
if (_wm == nullptr) {
return;
@@ -686,25 +682,24 @@ void WiFiManagerHandlers::handleRoot(AsyncWebServerRequest *request) {
// - WiFiManagerHandlers owns request-time shell assembly; WiFiManagerServer owns HTTP lifecycle only.
// - Build one request-scoped PortalShellRenderBundle.
// - Populate one request-scoped PlaceholderRegistry with shell defaults + request payloads.
// - Render WM_ROOT_SHELL_TEMPLATE.
// - Render WM_ROOT_SHELL_TEMPLATE with static CSS plus an optional small theme block.
auto bundle = std::make_shared<PortalShellRenderBundle>();
const bool hasCustomStyles = _wm != nullptr &&
(_wm->_portalAssets.overriddenCss.length() > 0 || _wm->_portalAssets.appendedCss.length() > 0);
bundle->pageTitleStatic = _wm ? _wm->_portalBrand.title : String();
bundle->pageTitle = _wm ? _wm->_portalBrand.title : WiFiManagerPortalText{};
bundle->bootstrapJson = buildPortalBootstrapJson();
bundle->appendJsStatic = _wm ? _wm->_portalAssets.appendedJs : String();
bundle->registry.registerProgmemData("%STYLES%", CSS_STYLE);
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);
if (_wm) {
configureDynamicStringDescriptor(bundle->themeDescriptor, _wm->_portalThemeStyle);
bundle->registry.registerDynamicData("%PORTAL_THEME%", &bundle->themeDescriptor);
} else {
bundle->registry.registerProgmemData("%STYLES%", CSS_STYLE);
bundle->registry.registerProgmemData("%PORTAL_THEME%", kEmptyPortalPlaceholder);
}
if (bundle->pageTitle.storage == WiFiManagerPortalStorage::Progmem && bundle->pageTitle.data) {
bundle->registry.registerProgmemData("%PAGE_TITLE%", bundle->pageTitle.data);
} else {
configurePortalTextDescriptor(bundle->pageTitleDescriptor, bundle->pageTitle);
bundle->registry.registerDynamicData("%PAGE_TITLE%", &bundle->pageTitleDescriptor);
}
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);
@@ -1076,7 +1071,9 @@ String WiFiManagerHandlers::buildPortalBootstrapJson() {
json += F("\",\"homeIntro\":\"");
jsonAppendEscaped(json, _wm->_portalBrand.homeIntro);
json += F("\",\"logoSvg\":\"");
jsonAppendEscaped(json, _wm->_portalBrand.logoSvg);
jsonAppendEscaped(json, _wm->_portalBrand.logo.svg);
json += F("\",\"logoAltText\":\"");
jsonAppendEscaped(json, _wm->_portalBrand.logoAltText);
json += F("\"}");
json += F(",\"context\":{");
json += F("\"portalActive\":");
@@ -1094,15 +1091,12 @@ String WiFiManagerHandlers::buildPortalBootstrapJson() {
json += String(timeoutRemainingSeconds);
}
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();
}
if (!_wm->_portalBrand.identityTextOverride.empty()) {
jsonAppendEscaped(json, _wm->_portalBrand.identityTextOverride);
} else {
const String idText = _wm->configPortalActive
? _wm->_apName
: _wm->getWiFiHostname() + " - " + WiFi.localIP().toString();
jsonAppendEscaped(json, idText);
}
json += F("\",\"statusSummary\":\"");
+3 -3
View File
@@ -47,18 +47,18 @@
{
"owner": "esp32async",
"name": "ESPAsyncTCP",
"version": "2.0.0",
"version": "^2.0.0",
"platforms": "espressif8266"
},
{
"owner": "esp32async",
"name": "AsyncTCP",
"version": "3.4.9",
"version": "^3.4.9",
"platforms": "espressif32"
},
{
"name": "DeviceFrameworkTemplateEngine",
"version": "https://github.com/alexhopeoconnor/DFTE.git#v1.0.2"
"version": "https://github.com/alexhopeoconnor/DFTE.git#v1.1.0"
}
],
"build": {
+6 -4
View File
@@ -16,11 +16,11 @@ build_flags =
-DUNIT_TEST
lib_deps =
ESP32Async/ESPAsyncWebServer@3.9.1
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.0.2
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.1.0
ESP32Async/ESPAsyncTCP@2.0.0
[env:esp32]
platform = espressif32@6.13.0
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
board = esp32dev
framework = arduino
monitor_speed = 115200
@@ -30,12 +30,14 @@ build_unflags =
-std=gnu++11
build_flags =
-std=gnu++14
-DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
-DWM_LOG_LEVEL=5
-DUNIT_TEST
lib_deps =
ESP32Async/ESPAsyncWebServer@3.9.1
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.0.2
ESP32Async/AsyncTCP@3.4.9
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.1.0
ESP32Async/AsyncTCP@^3.4.9
; Optional: compile tests with DFTE logs bridged into WiFiManager::log (see README)
[env:esp8266_dfte_log]
+1 -1
View File
@@ -35,7 +35,7 @@ while IFS= read -r file; do
done < "$file"
done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -type f -name '*.md' -print)
for required in README.md CHANGELOG.md docs/README.md docs/GETTING_STARTED.md docs/PORTAL_CUSTOMIZATION.md docs/PORTAL_API.md docs/TESTING.md docs/DEVELOPMENT.md; do
for required in README.md CHANGELOG.md docs/README.md docs/GETTING_STARTED.md docs/PORTAL_UI.md docs/PORTAL_API.md docs/TESTING.md docs/DEVELOPMENT.md; do
if [[ ! -f "$root/$required" ]]; then
printf 'Missing required documentation file: %s\n' "$required" >&2
failed=1
+7
View File
@@ -11,6 +11,13 @@ platform="${3:-}"
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cached_library="$root/test/compile-project/.pio/libdeps/${platform}/WiFiManager"
# The fixture intentionally declares only this local package. Remove a prior
# link so each check resolves the current manifest as a fresh consumer would.
if [[ -d "$cached_library" || -e "${cached_library}.pio-link" ]]; then
pio pkg uninstall -d "$root/test/compile-project" -e "$platform" \
-l WiFiManager --no-save --skip-dependencies >/dev/null
fi
pio run -d "$root/test/compile-project" -e "$platform"
echo "WiFiManager consumer compile check passed for $platform"
+3 -1
View File
@@ -20,11 +20,13 @@ lib_deps =
[env:esp32]
extends = common
platform = espressif32@6.13.0
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
-std=gnu++14
-DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
-DWM_LOG_LEVEL=3
lib_deps =
${common.lib_deps}
+36
View File
@@ -1,9 +1,45 @@
#include <Arduino.h>
#include <WiFiManager.h>
namespace {
const char kTitle[] PROGMEM = "Set up Compile Fixture";
const char kIdentity[] PROGMEM = "WiFiManager";
const char kIntro[] PROGMEM = "A branded portal compile check.";
const char kLogoAlt[] PROGMEM = "WiFiManager";
const char kLogo[] PROGMEM = "<svg viewBox='0 0 24 24'></svg>";
const char kPage[] PROGMEM = "#f4f7f3";
const char kSurface[] PROGMEM = "#ffffff";
const char kText[] PROGMEM = "#1c251e";
const char kMuted[] PROGMEM = "#607064";
const char kBorder[] PROGMEM = "#d6e0d7";
const char kAccent[] PROGMEM = "#347a45";
const char kAccentHover[] PROGMEM = "#245a32";
const char kAccentText[] PROGMEM = "#ffffff";
const WiFiManagerPortalConfig kPortalConfig = {
WiFiManagerPortalText::progmem(kTitle),
WiFiManagerPortalText::progmem(kIdentity),
WiFiManagerPortalText::progmem(kIntro),
WiFiManagerPortalAsset::svgFromProgmem(kLogo),
WiFiManagerPortalText::progmem(kLogoAlt),
{
WiFiManagerPortalText::progmem(kPage),
WiFiManagerPortalText::progmem(kSurface),
WiFiManagerPortalText::progmem(kText),
WiFiManagerPortalText::progmem(kMuted),
WiFiManagerPortalText::progmem(kBorder),
WiFiManagerPortalText::progmem(kAccent),
WiFiManagerPortalText::progmem(kAccentHover),
WiFiManagerPortalText::progmem(kAccentText),
{}, {}, {}, 10, 6,
},
};
} // namespace
WiFiManager wifiManager;
void setup() {
wifiManager.setPortalConfig(kPortalConfig);
wifiManager.setConfigPortalTimeout(1);
}
+3 -1
View File
@@ -36,9 +36,11 @@ 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_portal_default_presentation),
TEST_ENTRY(test_bootstrap_json_contract_v2),
TEST_ENTRY(test_bootstrap_json_snapshot_consistency),
TEST_ENTRY(test_root_render_interleaved_context_isolation),
TEST_ENTRY(test_portal_presentation_configuration),
// Non-blocking tests
TEST_ENTRY(test_nonblocking_process),
@@ -104,7 +106,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),
TEST_ENTRY(test_shell_template_renders_dynamic_theme_with_percent_values),
// State transition tests
TEST_ENTRY(test_portal_to_connected_transition),
+3 -1
View File
@@ -38,9 +38,11 @@ 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_portal_default_presentation();
void test_bootstrap_json_contract_v2();
void test_bootstrap_json_snapshot_consistency();
void test_root_render_interleaved_context_isolation();
void test_portal_presentation_configuration();
// Non-blocking tests
void test_nonblocking_process();
@@ -108,7 +110,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();
void test_shell_template_renders_dynamic_theme_with_percent_values();
// API JSON + captive helper tests
void test_captive_redirect_host_rule();
@@ -88,8 +88,10 @@ void test_ui_customization() {
WiFiManager wm;
// Minimal customization retained: title
wm.portalSetBrandTitle("MyDevice");
// Minimal customization uses the complete static portal configuration.
WiFiManagerPortalConfig portal;
portal.title = WiFiManagerPortalText::ram("MyDevice");
TEST_ASSERT_TRUE(wm.setPortalConfig(portal));
// All setters executed without crash
TEST_ASSERT_TRUE_MESSAGE(true, "UI customization options executed without crash");
@@ -39,8 +39,10 @@ void test_configuration_setters() {
wm.portalSetBehaviorCaptivePortalEnabled(true);
wm.portalSetBehaviorCaptivePortalEnabled(false);
// Test custom title path
wm.portalSetBrandTitle("TestTitle");
// Test the typed portal presentation path.
WiFiManagerPortalConfig portal;
portal.title = WiFiManagerPortalText::ram("TestTitle");
TEST_ASSERT_TRUE(wm.setPortalConfig(portal));
// Call setters multiple times with different values.
wm.setConfigPortalTimeout(60);
@@ -76,16 +76,29 @@ void test_bootstrap_json_portal_feature_flags() {
Serial.println("[TEST] Bootstrap portal feature flags test completed successfully");
}
void test_portal_default_presentation() {
WiFiManager wm;
WiFiManagerHandlers handlers(&wm);
const String bootstrap = handlers.buildPortalBootstrapJson();
TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"title\":\"WiFiManager\"")));
TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"homeIntro\":\"\"")));
TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"logoSvg\":\"\"")));
TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"logoAltText\":\"\"")));
}
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>");
WiFiManagerPortalConfig portal;
portal.title = WiFiManagerPortalText::ram("Solar Battery Monitor Setup");
portal.identityText = WiFiManagerPortalText::ram("Solar Battery Monitor");
portal.homeIntro = WiFiManagerPortalText::ram("Connect this device to WiFi and finish setup.");
portal.logo = WiFiManagerPortalAsset::svgFromRam("<svg viewBox='0 0 24 24'></svg>");
TEST_ASSERT_TRUE(wm.setPortalConfig(portal));
wm.portalSetPageInfoVisible(true);
wm.portalSetPageUpdateVisible(false);
wm.portalSetPageSetupVisible(true);
@@ -163,11 +176,11 @@ void test_root_render_interleaved_context_isolation() {
registryA.registerProgmemData("%STYLES%", kEmptyTemplateChunk);
registryA.registerProgmemData("%PAGE_TITLE%", kTestTitle);
registryA.registerProgmemData("%PORTAL_APPEND_JS%", kEmptyTemplateChunk);
registryA.registerProgmemData("%PORTAL_THEME%", kEmptyTemplateChunk);
registryB.registerProgmemData("%STYLES%", kEmptyTemplateChunk);
registryB.registerProgmemData("%PAGE_TITLE%", kTestTitle);
registryB.registerProgmemData("%PORTAL_APPEND_JS%", kEmptyTemplateChunk);
registryB.registerProgmemData("%PORTAL_THEME%", kEmptyTemplateChunk);
String bootstrapA = F("{\"ctx\":\"A\"}");
String appJsA = F("// shell A");
@@ -217,3 +230,51 @@ void test_root_render_interleaved_context_isolation() {
Serial.println("[TEST] Root render interleaved context isolation test completed successfully");
}
void test_portal_presentation_configuration() {
Serial.println("[TEST] Testing portal presentation configuration...");
WiFiManager wm;
WiFiManagerHandlers handlers(&wm);
WiFiManagerPortalConfig config;
config.title = WiFiManagerPortalText::ram("Set up Temperature Monitor");
config.identityText = WiFiManagerPortalText::ram("Tree");
config.homeIntro = WiFiManagerPortalText::ram("Connect this device to WiFi.");
config.logo = WiFiManagerPortalAsset::svgFromRam("<svg viewBox='0 0 24 24'></svg>");
config.logoAltText = WiFiManagerPortalText::ram("Tree logo");
config.theme.pageBackground = WiFiManagerPortalText::ram("#f4f7f3");
config.theme.surface = WiFiManagerPortalText::ram("#ffffff");
config.theme.text = WiFiManagerPortalText::ram("#1c251e");
config.theme.mutedText = WiFiManagerPortalText::ram("#607064");
config.theme.border = WiFiManagerPortalText::ram("#d6e0d7");
config.theme.accent = WiFiManagerPortalText::ram("#347a45");
config.theme.accentHover = WiFiManagerPortalText::ram("#245a32");
config.theme.accentText = WiFiManagerPortalText::ram("#ffffff");
config.theme.success = WiFiManagerPortalText::ram("#2f855a");
config.theme.danger = WiFiManagerPortalText::ram("#c53030");
config.theme.dangerHover = WiFiManagerPortalText::ram("#9b2c2c");
config.theme.cornerRadiusPx = 10;
config.theme.smallCornerRadiusPx = 6;
TEST_ASSERT_TRUE_MESSAGE(wm.setPortalConfig(config),
"A complete setup-time portal configuration should be accepted");
String bootstrap = handlers.buildPortalBootstrapJson();
TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"title\":\"Set up Temperature Monitor\"")));
TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"identityText\":\"Tree\"")));
TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"logoAltText\":\"Tree logo\"")));
WiFiManagerPortalConfig unsafeConfig = config;
unsafeConfig.theme.accent = WiFiManagerPortalText::ram("#347a45;body{display:none}");
TEST_ASSERT_FALSE_MESSAGE(wm.setPortalConfig(unsafeConfig),
"Semantic theme values must reject stylesheet injection characters");
wm.startWebPortal();
TEST_ASSERT_FALSE_MESSAGE(wm.setPortalConfig(config),
"Portal presentation must remain immutable while async routes are active");
wm.stopWebPortal();
TEST_ASSERT_TRUE_MESSAGE(wm.setPortalConfig(config),
"Presentation can be applied after the portal stops");
Serial.println("[TEST] Portal presentation configuration test completed successfully");
}
@@ -28,7 +28,7 @@ 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 kPortalTheme[] PROGMEM = "";
const char* dynamicStringGetter(void* userData) {
const auto* value = static_cast<const String*>(userData);
@@ -55,7 +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);
registry.registerProgmemData("%PORTAL_THEME%", kPortalTheme);
String output = renderTemplate(WM_ROOT_SHELL_TEMPLATE, registry);
@@ -70,35 +70,32 @@ 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...");
void test_shell_template_renders_dynamic_theme_with_percent_values() {
Serial.println("[TEST] Testing portal shell dynamic theme placeholder with percent values...");
PlaceholderRegistry registry(8);
String styles = F("<style>body{width:100%;max-width:92%}</style>");
String theme = F("<style>:root{--wm-bg:rgb(20,50,30);--wm-width:100%}</style>");
String title = F("Config ESP");
String bootstrap = F("{\"title\":\"Test\"}");
String appendJs = F("");
DynamicDataDescriptor stylesDescriptor{};
DynamicDataDescriptor themeDescriptor{};
DynamicDataDescriptor titleDescriptor{};
DynamicDataDescriptor bootstrapDescriptor{};
DynamicDataDescriptor appendJsDescriptor{};
configureDescriptor(stylesDescriptor, styles);
configureDescriptor(themeDescriptor, theme);
configureDescriptor(titleDescriptor, title);
configureDescriptor(bootstrapDescriptor, bootstrap);
configureDescriptor(appendJsDescriptor, appendJs);
TEST_ASSERT_TRUE(registry.registerDynamicData("%STYLES%", &stylesDescriptor));
TEST_ASSERT_TRUE(registry.registerProgmemData("%STYLES%", kEmpty));
TEST_ASSERT_TRUE(registry.registerDynamicData("%PORTAL_THEME%", &themeDescriptor));
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("<style>:root{--wm-bg:rgb(20,50,30);--wm-width:100%}</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");
Serial.println("[TEST] Dynamic theme placeholder percent rendering test completed successfully");
}