Compare commits

...
3 Commits
Author SHA1 Message Date
alex c529fc15a8 Release v3.1.0 2026-09-01 10:59:25 +10:00
alex 25977a7320 feat: prepare WiFiManager 3.1.0 2026-08-27 14:25:38 +10:00
alex c10f1a3e92 fix: resolve PlatformIO async dependencies 2026-08-26 11:40:37 +10:00
36 changed files with 1775 additions and 535 deletions
+15 -1
View File
@@ -2,12 +2,26 @@ name: Build
on:
push:
branches:
- device-framework
tags:
- "v*"
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
documentation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./scripts/check-docs.sh
compile-tests:
runs-on: ubuntu-latest
strategy:
@@ -20,4 +34,4 @@ jobs:
with:
python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19
- run: ./scripts/compile-check.sh --platform ${{ matrix.environment }}
- run: ./scripts/test.sh compile --platform ${{ matrix.environment }}
+8 -1
View File
@@ -17,7 +17,14 @@ jobs:
with:
python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19
- run: ./scripts/test.sh compile --platform esp8266
- run: ./scripts/test.sh compile --platform esp32
- run: ./scripts/check-docs.sh
- run: ./scripts/prepare-release.sh "$GITHUB_REF_NAME"
- run: gh release create "$GITHUB_REF_NAME" --generate-notes --title "$GITHUB_REF_NAME"
- run: ./scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md"
- run: >-
gh release create "$GITHUB_REF_NAME"
--title "WiFiManager $GITHUB_REF_NAME"
--notes-file "$RUNNER_TEMP/release-notes.md"
env:
GH_TOKEN: ${{ github.token }}
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 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
- Correct async PlatformIO dependency owners to the registry's canonical lowercase identity, so a clean consumer builds WiFiManager and its ESP8266/ESP32 transport dependencies without duplicating them in `lib_deps`. Remove the superseded include-path bridge.
## 3.0.5
- Add a library-owned PlatformIO bridge for the asynchronous web/TCP include paths required when WiFiManager is nested beneath DeviceFramework.
+45 -232
View File
@@ -1,18 +1,15 @@
# WiFiManager
This repository is a **breaking fork** of upstream [`tzapu/WiFiManager`](https://github.com/tzapu/WiFiManager).
It is not a drop-in replacement for upstream behavior, APIs, templates, or portal customization patterns.
WiFiManager is the maintained ESP8266/ESP32 configuration-portal fork used by DeviceFramework. It is a deliberate breaking fork of upstream [`tzapu/WiFiManager`](https://github.com/tzapu/WiFiManager), with a streamed single-page portal and typed JSON APIs rather than upstream’s legacy page/template model.
If you are evaluating this fork, assume that core web-portal architecture has changed and review the code before adopting it in an existing upstream-based project.
## Why use it
**This repository:** [alexhopeoconnor/WiFiManager](https://github.com/alexhopeoconnor/WiFiManager)
**Upstream:** [tzapu/WiFiManager](https://github.com/tzapu/WiFiManager)
- **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 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.
## Basic provisioning
For a standalone Arduino project, create one long-lived `WiFiManager` and call
`autoConnect` during setup. It first tries saved station credentials; when that
fails it starts the configuration portal with the supplied AP name and password.
## Try it
```cpp
#include <Arduino.h>
@@ -22,256 +19,72 @@ WiFiManager wifi;
void setup() {
Serial.begin(115200);
wifi.setConfigPortalTimeout(180); // seconds; 0 leaves it open
wifi.setConfigPortalTimeout(180);
if (!wifi.autoConnect("Device Setup", "change-me")) {
ESP.restart();
}
}
void loop() {
// Normal application work after WiFi is connected.
}
void loop() {}
```
The AP password must meet the Wi-Fi password requirements. For applications
built on DeviceFramework, configure `setConfigDevicePassword(...)` instead:
DeviceFramework passes the same validated password to WiFiManager, Arduino OTA,
HTTP Basic authentication, and WebSerial.
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.
## Breaking Changes
This fork intentionally modernizes and restructures the configuration portal.
Notable differences from upstream include:
- The portal now serves a **single HTML shell** from `GET /`.
- Client navigation is handled as a **SPA with hash routing**.
- Interactive behavior is exposed through **JSON APIs under `/api/...`** plus firmware upload at **`POST /u`**.
- Legacy multi-page portal routes and legacy root-template override paths have been removed.
- The portal rendering pipeline is built around **DFTE** instead of upstream's monolithic HTML string assembly.
- JSON endpoints are expected to be **data-first**, not derived from generated HTML fragments.
## Current Improvements And Modernization
This fork currently includes the following architectural improvements:
- A **single-shell portal architecture** with embedded bootstrap JSON and embedded application JS.
- A **SPA-based configuration UI** for WiFi setup, parameters, info views, device actions, and OTA flow.
- A **clean API surface** for WiFi scanning, WiFi save, parameters, info, status, restart, erase, portal exit, and captive-portal close behavior.
- **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.
- **Connect-on-save handoff**: when portal save triggers a station connect, `/api/wifi/connect-status` now reports a brief success state with `stationIp` and `redirectUrl` so the SPA can show the new address and redirect before the AP shuts down.
- **Request-scoped shell rendering**: the root portal page is built for each `GET /` from `WM_ROOT_SHELL_TEMPLATE` using a fresh placeholder registry. Shell inputs are `%PAGE_TITLE%`, `%STYLES%`, `%BOOTSTRAP_JSON%`, `%PORTAL_APP_JS%`, and `%PORTAL_APPEND_JS%` — filled in `WiFiManagerHandlers` from WiFiManager state and embedded assets (not from a server-wide template registry).
- **Customization via WiFiManager `portal*` APIs** (`portalSetBrandTitle`, `portalSetPageInfoVisible`, `portalSetLayoutParamsLocation`, `portalAddParameter`, asset hooks, etc.) and JSON under `/api/...`, not by exposing placeholder-registry mutation to consumers.
- A clearer separation between:
- shell rendering (handlers + SPA bootstrap)
- JSON API responses
- captive portal behavior
- 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
### Brand it
```cpp
WiFiManager wm;
const char kTitle[] PROGMEM = "Set up Temperature Monitor";
const char kBrand[] PROGMEM = "Tree";
const char kAccent[] PROGMEM = "#347a45";
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>"
);
const WiFiManagerPortalConfig kPortalUI = {
WiFiManagerPortalText::progmem(kTitle),
WiFiManagerPortalText::progmem(kBrand),
{}, {}, {},
{
{}, {}, {}, {}, {},
WiFiManagerPortalText::progmem(kAccent),
{}, {}, {}, {}, {},
10, 0,
},
};
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);"
"});"
);
```
## WiFi Connect Status API
When `portalSetBehaviorConnectOnSave(true)` is enabled, saving WiFi credentials queues a station join and the SPA polls `GET /api/wifi/connect-status`.
Response shape:
```json
{
"state": "waiting | success | failed",
"message": "human readable status",
"wifiStatus": "WL_CONNECTED",
"stationIp": "192.168.1.42",
"redirectUrl": "http://192.168.1.42/"
void setup() {
wifi.setPortalConfig(kPortalUI); // before the portal starts
wifi.autoConnect("Device Setup");
}
```
Notes:
The full standalone example, including a static SVG, is [BrandedPortal](examples/BrandedPortal/BrandedPortal.ino).
- `stationIp` and `redirectUrl` are present only after a successful station join.
- If the portal HTTP server is not on port `80`, `redirectUrl` includes the active port.
- On success, WiFiManager keeps the portal alive briefly so the client can read the success payload and navigate to the new device address before the captive AP is shut down.
- This improves the handoff on typical home networks, but it is not a universal guarantee: client captive-portal helpers, browser behavior, DHCP timing, and network isolation can still affect whether the redirect completes automatically.
## Explore the portal
## Dependencies
| Goal | Guide |
| --- | --- |
| 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) |
This fork depends on **DFTE** ([Device Framework Template Engine](https://github.com/alexhopeoconnor/DFTE)) and **ESP32Async/ESPAsyncWebServer**.
The published `library.json` resolves DFTE from the maintained, pinned
`v1.0.2` Git tag. No sibling checkout is required to consume WiFiManager.
When developing both libraries together, use a local `symlink://` or
`file://` dependency in your own ignored PlatformIO override.
## Installation
Use the release tag and list the asynchronous web/TCP packages at project level.
PlatformIO installs the manifest dependencies automatically, but its dependency
finder requires these headers to be direct project dependencies before it adds
their include paths:
## Install
```ini
[common]
lib_deps =
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.0.5
ESP32Async/ESPAsyncWebServer@3.9.1
[env:esp8266]
extends = common
platform = espressif8266
board = d1_mini
framework = arduino
lib_deps =
${common.lib_deps}
ESP32Async/ESPAsyncTCP@2.0.0
[env:esp32]
extends = common
lib_deps =
platform = espressif32
board = esp32dev
framework = arduino
${common.lib_deps}
ESP32Async/AsyncTCP@3.4.9
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.1.0
```
The text after `#` is a Git ref. PlatformIO clones the repository and checks
out that tag; it does not download a GitHub Release asset. A tag gives a
reproducible library input. Use a local checkout only while actively changing
the library:
The suffix after `#` is a Git ref. PlatformIO clones the repository and checks out that release tag; GitHub Release assets are unrelated. This repository is supported through PlatformIO. Use a local `symlink://` or `file://` dependency only while actively changing this fork.
```ini
lib_deps =
WiFiManager=file:///path/to/WiFiManager
```
## Tests and releases
The CI workflow compiles a minimal clean consumer project for both supported
targets. These commands need no connected board:
```bash
./scripts/compile-check.sh --platform esp8266
./scripts/compile-check.sh --platform esp32
```
Before publishing a version, update `library.json`, `CHANGELOG.md`, and this
README, run both checks, then create the annotated tag:
## Development and releases
```bash
./scripts/test.sh compile --platform esp8266
./scripts/test.sh compile --platform esp32
./scripts/check-docs.sh
./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag
```
Push the branch and tag. GitHub Actions validates the PlatformIO package again
and creates the GitHub Release from that tag.
Tagging repeats the board-free compile checks, validates the package, and creates a GitHub Release from the corresponding changelog section. It does not publish to the PlatformIO Registry or deploy firmware.
## AI Assistance Notice
Parts of this codebase have been developed and refactored with the aid of AI coding agents under human direction and review.
## License
See [LICENSE](LICENSE).
See the [documentation index](docs/README.md), [release history](CHANGELOG.md), and [licence](LICENSE).
+28
View File
@@ -0,0 +1,28 @@
# Development and releases
Released consumers use the public Git tag. While changing WiFiManager and a sibling dependency together, point an ignored local PlatformIO override at a `symlink://` or `file://` checkout rather than changing tracked application dependencies.
```ini
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
./scripts/check-docs.sh
./scripts/test.sh compile --platform esp8266
./scripts/test.sh compile --platform esp32
./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag
```
Push the branch and annotated tag. GitHub Actions repeats the board-free compile checks, validates the package, and creates a GitHub Release using that version’s changelog section. The workflow does not publish to the PlatformIO Registry.
Back to [documentation](README.md) · [project overview](../README.md).
+41
View File
@@ -0,0 +1,41 @@
# Getting started
WiFiManager owns station credential recovery and a temporary configuration portal. Keep one instance for the life of the application.
```cpp
#include <Arduino.h>
#include <WiFiManager.h>
WiFiManager wifi;
void setup() {
Serial.begin(115200);
wifi.setConfigPortalTimeout(180);
if (!wifi.autoConnect("Example Setup", "change-me")) {
ESP.restart();
}
}
void loop() {
// Normal work begins after autoConnect succeeds.
}
```
`autoConnect()` first tries stored station credentials. If that cannot connect, it starts the AP and portal with the supplied name and password. Use a Wi-Fi-valid AP password.
## PlatformIO dependency
```ini
lib_deps =
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.1.0
```
The package manifest resolves DFTE, ESPAsyncWebServer, and the correct TCP transport for ESP8266 or ESP32. A consuming project should not duplicate those dependencies unless it is deliberately testing an unreleased stack change.
## DeviceFramework applications
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 UI](PORTAL_UI.md) or [portal API](PORTAL_API.md).
Back to [documentation](README.md) · [project overview](../README.md).
+34
View File
@@ -0,0 +1,34 @@
# Portal API
The portal uses JSON endpoints under `/api/...` for WiFi scans and saves, parameters, information, status, restart/erase/exit actions, captive-portal closure, and station-connect status. Consumers should treat the documented response shapes as the contract; portal HTML is not an API.
## Profile-mode WiFi metadata
When an application supplies a `WiFiManagerStationProfileStore`, `GET /api/wifi/meta` reports a primary profile and optional fallback without returning passwords. `POST /api/wifi/save` verifies a submitted candidate before committing it; see [station profiles](STATION_PROFILES.md) for fields and lifecycle.
## WiFi connect status
When `portalSetBehaviorConnectOnSave(true)` is enabled, saving credentials queues a station join. The SPA polls `GET /api/wifi/connect-status`:
```json
{
"state": "waiting | success | failed",
"message": "human readable status",
"wifiStatus": "WL_CONNECTED",
"stationIp": "192.168.1.42",
"redirectUrl": "http://192.168.1.42/"
}
```
`stationIp` and `redirectUrl` are present only after a successful join. If the portal server is not on port 80, `redirectUrl` includes that port.
On success, WiFiManager keeps the portal alive briefly so the client can read the final status and navigate before the AP is shut down. Captive-portal helpers, DHCP timing, browser behaviour, and network isolation can still prevent an automatic redirect, so clients must handle a visible address as well.
## API design rules
- Use JSON endpoint results for state and actions.
- Keep UI-visible capabilities in the portal bootstrap/API payloads.
- Add a documented endpoint contract before adding a new portal workflow.
- Do not derive state by parsing the rendered shell.
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).
+20
View File
@@ -0,0 +1,20 @@
# WiFiManager documentation
This maintained fork intentionally has a narrower, explicit customisation boundary than upstream WiFiManager.
| I want to… | Read |
| --- | --- |
| Start a basic portal or install a released dependency | [Getting started](GETTING_STARTED.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) |
| Work on this fork or make a release | [Development and releases](DEVELOPMENT.md) |
## Documentation rules
- 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.
Back to the [project overview](../README.md).
+62
View File
@@ -0,0 +1,62 @@
# Station profiles
WiFiManager 3.1.0 adds an opt-in station-profile controller for applications that need a primary Wi-Fi network and one fallback. It is independent of the legacy `autoConnect()` flow: existing WiFiManager consumers do not need to change.
DeviceFramework enables this controller and supplies its CRC-protected storage. A direct WiFiManager consumer supplies its own durable store, or can use the controller only for the current process.
## Lifecycle
A profile set has exactly two fixed slots:
- **Primary** (slot 0) is required whenever the controller is enabled.
- **Fallback** (slot 1) is optional.
- The last successful slot is tried first on the next connection cycle, then the remaining enabled slot.
The controller never treats the ESP SDK's saved single network as an additional source of truth. It begins one bounded connection attempt at a time, moves to the fallback after failure, and retries both profiles after a temporary loss of a previously working connection. If a new device has no valid profiles, it opens the normal configuration portal.
A candidate submitted by the portal is only committed after it connects and receives a usable IP address. A failed candidate leaves the last saved profile set intact.
## Direct WiFiManager use
Implement a small store appropriate to the application. The manager neither allocates nor owns it:
```cpp
class MyProfileStore final : public WiFiManagerStationProfileStore {
public:
bool load(WiFiManagerStationProfiles& profiles) override;
bool save(const WiFiManagerStationProfiles& profiles) override;
bool clear() override;
};
WiFiManager wifi;
MyProfileStore profiles;
void setup() {
wifi.setStationProfileStore(&profiles);
wifi.setStationRecoveryInterval(30000);
wifi.startStationConnection("Example Setup", "setup-password");
}
void loop() {
wifi.process();
}
```
The store must return a complete `WiFiManagerStationProfiles` value. Each enabled profile has a NUL-terminated SSID of at most 32 characters and an optional NUL-terminated password of at most 64 characters. Keep slot 0 enabled; set `hasPassword = false` for an open network.
To stage profiles supplied by another subsystem, use `startStationCandidate(candidate)`. It connects the primary/fallback set first and calls the store only after success. Use `saveStationProfiles(profiles)` only when deliberately saving without a connection check. `clearStationProfiles()` clears the supplied store and disconnects the station.
## Portal contract
In profile mode, the existing Wi-Fi page becomes a two-profile form. It remains driven by the same JSON endpoints:
- `GET /api/wifi/meta` returns `profiles`, `activeSlot`, and controller `state`.
- `POST /api/wifi/save` accepts `s0`/ `p0` for primary and `s1`/ `p1` for fallback. A blank submitted password preserves an existing password; send `clear0` or `clear1` for an intentional open network.
- A normal save verifies the candidate by connecting. Poll `GET /api/wifi/connect-status` for its result.
- Set `stationAction=save` only to store the submitted profiles for a later connection attempt.
The portal requires a non-empty primary SSID. Its API never returns a password; it only reports whether one is set.
See [Portal API](PORTAL_API.md) for the shared connection-status response and [DeviceFramework configuration](https://github.com/alexhopeoconnor/DeviceFramework/blob/main/docs/CONFIGURATION.md) for the local-profile JSON that supplies these slots.
Back to [documentation](README.md) · [project overview](../README.md).
+14
View File
@@ -0,0 +1,14 @@
# 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 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
./scripts/test.sh compile --platform esp32
```
CI runs both checks for pushes to the maintained branch and pull requests. They compile only; hardware portals remain a local integration concern for an application or DeviceFramework’s connected-device suite.
Back to [documentation](README.md) · [project overview](../README.md).
+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() {}
+194 -90
View File
@@ -1,9 +1,9 @@
/**
* WiFiManager.h
*
*
* WiFiManager, a library for the ESP8266/Arduino platform
* for configuration of WiFi credentials using a Captive Portal
*
*
* @author Creator tzapu
* @author tablatronix
* @author alexhopeoconnor
@@ -22,6 +22,7 @@
#include <vector>
#include <functional>
#include <string.h>
#include "WiFiManagerParameter.h"
#include "WiFiManagerLogLevel.h"
@@ -39,13 +40,13 @@
// #define WM_DFTE_LOGGING // opt-in: bridge DFTE logging into WiFiManager (see README)
// #define WM_MDNS // includes MDNS, also set MDNS with sethostname
// #define WM_FIXERASECONFIG // use erase flash fix
// #define WM_ERASE_NVS // esp32 erase(true) will erase NVS
// #define WM_ERASE_NVS // esp32 erase(true) will erase NVS
// #define WM_RTC // esp32 info page will include reset reasons
// #define WIFI_MANAGER_OVERRIDE_STRINGS // build flag for using own strings include
#ifdef ARDUINO_ESP8266_RELEASE_2_3_0
#warning "ARDUINO_ESP8266_RELEASE_2_3_0, some WM features disabled"
#warning "ARDUINO_ESP8266_RELEASE_2_3_0, some WM features disabled"
// @todo check failing on platform = espressif8266@1.7.3
#define WM_NOASYNC // esp8266 no async scan wifi
#define WM_NOCOUNTRY // esp8266 no country
@@ -91,16 +92,16 @@
#include <ESP8266mDNS.h>
#endif
#define WIFI_getChipId() ESP.getChipId()
#define WIFI_getChipId() ESP.getChipId()
#define WM_WIFIOPEN ENC_TYPE_NONE
#elif defined(ESP32)
#include <WiFi.h>
#include <esp_wifi.h>
#include <esp_wifi.h>
#include <Update.h>
#include <AsyncTCP.h>
#define WIFI_getChipId() (uint32_t)ESP.getEfuseMac()
#define WM_WIFIOPEN WIFI_AUTH_OPEN
@@ -141,13 +142,46 @@
#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
// application/controller rather than the SDK's saved station configuration.
constexpr uint8_t WM_STATION_PROFILE_COUNT = 2;
constexpr uint8_t WM_NO_STATION_PROFILE = 0xFF;
struct WiFiManagerStationProfile {
bool enabled = false;
bool hasPassword = false;
char ssid[33] = {};
char password[65] = {};
};
struct WiFiManagerStationProfiles {
WiFiManagerStationProfile slots[WM_STATION_PROFILE_COUNT] = {};
uint8_t preferredSlot = 0;
uint8_t lastSuccessfulSlot = WM_NO_STATION_PROFILE;
};
/**
* Optional durable backing for multi-profile station credentials. The manager
* owns profile policy and never owns this store. A consumer that needs durable
* profiles supplies one; otherwise the profiles remain in RAM.
*/
class WiFiManagerStationProfileStore {
public:
virtual bool load(WiFiManagerStationProfiles& profiles) = 0;
virtual bool save(const WiFiManagerStationProfiles& candidate) = 0;
virtual bool clear() = 0;
virtual ~WiFiManagerStationProfileStore() = default;
};
// prep string concat vars
#define WM_STRING2(x) #x
#define WM_STRING(x) WM_STRING2(x)
// WiFiManager version
const char WM_VERSION_STR[] PROGMEM = "v2.0.19";
const char WM_VERSION_STR[] PROGMEM = "v3.1.0";
// #include <esp_idf_version.h>
#ifdef ESP_IDF_VERSION
@@ -155,7 +189,7 @@ const char WM_VERSION_STR[] PROGMEM = "v2.0.19";
// #pragma message "ESP_IDF_VERSION_MINOR = " WM_STRING(ESP_IDF_VERSION_MINOR)
// #pragma message "ESP_IDF_VERSION_PATCH = " WM_STRING(ESP_IDF_VERSION_PATCH)
#define VER_IDF_STR WM_STRING(ESP_IDF_VERSION_MAJOR) "." WM_STRING(ESP_IDF_VERSION_MINOR) "." WM_STRING(ESP_IDF_VERSION_PATCH)
#else
#else
#define VER_IDF_STR "Unknown"
#endif
@@ -184,7 +218,7 @@ const char WM_VERSION_STR[] PROGMEM = "v2.0.19";
#define VER_ARDUINO_STR "Unknown"
#endif
#endif
#else
#else
#define VER_ARDUINO_STR "Unknown"
#endif
@@ -232,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 {
@@ -257,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;
@@ -327,6 +356,27 @@ class WiFiManager
WM_CP_CONNECT_FAILED,
};
enum wm_station_state_t : uint8_t {
WM_STATION_IDLE = 0,
WM_STATION_LOADING,
WM_STATION_ATTEMPTING,
WM_STATION_SWITCHING,
WM_STATION_CONNECTED,
WM_STATION_BACKOFF,
WM_STATION_PORTAL,
};
struct wm_station_status_t {
wm_station_state_t state = WM_STATION_IDLE;
uint8_t activeSlot = WM_NO_STATION_PROFILE;
uint8_t attemptedSlot = WM_NO_STATION_PROFILE;
uint8_t configuredProfiles = 0;
uint8_t wifiStatus = WL_IDLE_STATUS;
bool lastConnectionWasCandidate = false;
bool storageSaveFailed = false;
String message = "Idle";
};
/** Optional notification hook; prefer getters for consumers. */
enum wm_event_t : uint8_t {
WM_EVENT_PORTAL_STARTED = 0,
@@ -334,10 +384,16 @@ class WiFiManager
WM_EVENT_PORTAL_CONNECT_QUEUED,
WM_EVENT_PORTAL_CONNECT_START,
WM_EVENT_PORTAL_CONNECT_SUCCESS,
WM_EVENT_PORTAL_CONNECT_FAILED
WM_EVENT_PORTAL_CONNECT_FAILED,
WM_EVENT_STATION_PROFILE_ATTEMPT,
WM_EVENT_STATION_PROFILE_CONNECTED,
WM_EVENT_STATION_PROFILE_FAILED,
WM_EVENT_STATION_LINK_LOST,
WM_EVENT_STATION_BACKOFF,
WM_EVENT_STATION_PROFILES_CLEARED
};
using WiFiManagerEventCallback = std::function<void(wm_event_t)>;
WiFiManager(Print& consolePort);
WiFiManager();
~WiFiManager();
@@ -347,14 +403,28 @@ class WiFiManager
boolean autoConnect();
boolean autoConnect(char const *apName, char const *apPassword = NULL);
// Fixed two-profile station mode. When a store is attached, WiFiManager
// owns profile selection and explicit connection attempts rather than the
// platform's one saved station configuration.
void setStationProfileStore(WiFiManagerStationProfileStore* store);
bool startStationConnection(char const *apName = NULL, char const *apPassword = NULL);
bool startStationCandidate(const WiFiManagerStationProfiles& candidate);
bool startStationCandidate(const WiFiManagerStationProfiles& candidate, char const *apName, char const *apPassword = NULL);
bool saveStationProfiles(const WiFiManagerStationProfiles& profiles);
void clearStationProfiles();
bool isStationProfileMode() const;
void setStationRecoveryInterval(unsigned long intervalMs);
const WiFiManagerStationProfiles& getStationProfiles() const;
const wm_station_status_t& getStationStatus() const;
//manually start the config portal, autoconnect does this automatically on connect failure
void startConfigPortal(); // auto generates apname
void startConfigPortal(char const *apName, char const *apPassword = NULL);
//manually stop the config portal - immediately shuts down the portal
void stopConfigPortal();
//manually start the web portal, autoconnect does this automatically on connect failure
//manually start the web portal, autoconnect does this automatically on connect failure
void startWebPortal();
//manually stop the web portal if started manually
@@ -438,13 +508,13 @@ class WiFiManager
// sets number of retries for autoconnect, force retry after wait failure exit
void setConnectRetries(uint8_t numRetries); // default 1
//sets timeout for which to attempt connecting on saves, useful if there are bugs in esp waitforconnectloop
void setSaveConnectTimeout(unsigned long seconds);
// lets you disable automatically connecting after save from webportal
void setSaveConnect(bool connect = true);
void setLogEnabled(boolean enabled);
void setLogPrefix(String prefix);
void setLogOutput(boolean enabled, WiFiManagerLogLevel maxLevel);
@@ -463,44 +533,44 @@ class WiFiManager
//set min quality percentage to include in scan, defaults to 8% if not specified
void setMinimumSignalQuality(int quality = 8);
//sets a custom ip /gateway /subnet configuration
void setAPStaticIPConfig(IPAddress ip, IPAddress gw, IPAddress sn);
//sets config for a static IP
void setSTAStaticIPConfig(IPAddress ip, IPAddress gw, IPAddress sn);
//sets config for a static IP with DNS
void setSTAStaticIPConfig(IPAddress ip, IPAddress gw, IPAddress sn, IPAddress dns);
//if this is set, it will exit after config, even if connection is unsuccessful.
void setBreakAfterConfig(boolean shouldBreak);
//if this is true, remove duplicated Access Points - defaut true
void setRemoveDuplicateAPs(boolean removeDuplicates);
//setter for ESP wifi.persistent so we can remember it and restore user preference, as WIFi._persistent is protected
void setRestorePersistent(boolean persistent);
//if true, always show static net inputs, IP, subnet, gateway, else only show if set via setSTAStaticIPConfig
void setShowStaticFields(boolean alwaysShow);
//if true, always show static dns, esle only show if set via setSTAStaticIPConfig
void setShowDnsFields(boolean alwaysShow);
//if false, timeout captive portal even if a STA client connected to softAP (false), suggest disabling if captiveportal is open
void setAPClientCheck(boolean enabled);
//if true, reset timeout when webclient connects (true), suggest disabling if captiveportal is open
//if true, reset timeout when webclient connects (true), suggest disabling if captiveportal is open
void setWebPortalClientCheck(boolean enabled);
// if true, enable autoreconnecting
void setWiFiAutoReconnect(boolean enabled);
// if true, wifiscan will show percentage instead of quality icons, until we have better templating
void setScanDispPerc(boolean enabled);
// if true (default) then start the config portal from autoConnect if connection failed
void setEnableConfigPortal(boolean enable);
@@ -513,18 +583,17 @@ class WiFiManager
// set ap channel
void setWiFiAPChannel(int32_t channel);
// set ap hidden
void setWiFiAPHidden(bool hidden); // default false
// 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);
@@ -557,16 +626,12 @@ 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();
// get a status as string
String getWLStatusString(uint8_t status);
String getWLStatusString();
String getWLStatusString(uint8_t status);
String getWLStatusString();
// get wifi mode as string
String getModeString(uint8_t mode);
@@ -574,7 +639,7 @@ class WiFiManager
// check if the module has a saved ap to connect to
bool getWiFiIsSaved();
// helper to get saved password, if persistent get stored, else get current if connected
// helper to get saved password, if persistent get stored, else get current if connected
String getWiFiPass(bool persistent = true);
// helper to get saved ssid, if persistent get stored, else get current if connected
@@ -588,17 +653,17 @@ class WiFiManager
// helper for html
String htmlEntities(String str, bool whitespace = false);
// set the country code for wifi settings, CN
void setCountry(String cc);
// get default ap esp uses , esp_chipid etc
String getDefaultAPName();
// set the WiFi SSID prefix for default AP name, default platform-specific (ESP/ESP32/WM)
void setWiFiSSIDPrefix(String prefix);
// set port of webserver, 80
void setHttpPort(uint16_t port);
@@ -615,7 +680,7 @@ class WiFiManager
uint8_t getConfigPortalConnectStatus() const;
String getConfigPortalConnectMessage() const;
void setEventCallback(WiFiManagerEventCallback cb);
// check if web portal is active (true)
bool getWebPortalActive();
@@ -624,10 +689,10 @@ class WiFiManager
// get hostname helper
String getWiFiHostname();
// get server instance (for testing)
AsyncWebServer* getServer();
// get DNS server instance (for testing)
DNSServer* getDNSServer();
@@ -636,7 +701,7 @@ class WiFiManager
class WiFiManagerRequestArgs {
public:
std::unordered_map<std::string, std::string> args;
// Constructor - builds from AsyncWebServerRequest
WiFiManagerRequestArgs(AsyncWebServerRequest* request) {
if (request) {
@@ -647,19 +712,19 @@ class WiFiManager
}
}
}
// Default constructor for tests and manually assembled argument sets
WiFiManagerRequestArgs() {}
// Check if argument exists
bool hasArg(const char* name) const {
return args.find(std::string(name)) != args.end();
}
bool hasArg(const String& name) const {
return hasArg(name.c_str());
}
// Get argument value as String
String getArg(const char* name, const String& defaultValue = "") const {
auto it = args.find(std::string(name));
@@ -668,29 +733,29 @@ class WiFiManager
}
return defaultValue;
}
String getArg(const String& name, const String& defaultValue = "") const {
return getArg(name.c_str(), defaultValue);
}
// Type conversion helpers
int getArgAsInt(const char* name, int defaultValue = 0) const {
String value = getArg(name);
return value.length() > 0 ? value.toInt() : defaultValue;
}
float getArgAsFloat(const char* name, float defaultValue = 0.0f) const {
String value = getArg(name);
return value.length() > 0 ? value.toFloat() : defaultValue;
}
bool getArgAsBool(const char* name, bool defaultValue = false) const {
String value = getArg(name);
if (value.length() == 0) return defaultValue;
return value == "1" || value.equalsIgnoreCase("true") ||
return value == "1" || value.equalsIgnoreCase("true") ||
value.equalsIgnoreCase("on") || value.equalsIgnoreCase("yes");
}
size_t count() const {
return args.size();
}
@@ -723,7 +788,7 @@ class WiFiManager
WiFiScanRuntimeState _scan;
std::vector<WiFiScanNetwork> _scanResultsCache;
bool _scanLifecycleBlocked = false;
// async reboot/abort scheduling
bool _rebootScheduled = false; // flag for scheduled reboot
unsigned long _rebootTime = 0; // ms when reboot should occur
@@ -742,13 +807,30 @@ class WiFiManager
String _defaultssid = ""; // preload ssid
String _defaultpass = ""; // preload pass
WiFiManagerStationProfileStore* _stationProfileStore = nullptr;
WiFiManagerStationProfiles _stationProfiles;
WiFiManagerStationProfiles _stationCandidate;
wm_station_status_t _stationStatus;
bool _stationProfilesLoaded = false;
bool _stationCandidateActive = false;
bool _stationCandidateFromPortal = false;
bool _stationEverConnected = false;
uint8_t _stationAttemptMask = 0;
uint8_t _stationPendingSlot = WM_NO_STATION_PROFILE;
unsigned long _stationNextAttemptAt = 0;
unsigned long _stationAttemptStartedAt = 0;
unsigned long _stationBackoffStartedAt = 0;
unsigned long _stationRecoveryInterval = 5000UL;
String _stationPortalApName = "";
String _stationPortalApPassword = "";
// options flags
unsigned long _configPortalTimeout = 0; // ms close config portal loop if set (depending on _cp/webClientCheck options)
unsigned long _connectTimeout = 0; // ms stop trying to connect to ap if set
unsigned long _saveTimeout = 0; // ms stop trying to connect to ap on saves, in case bugs in esp waitforconnectresult
WiFiMode_t _usermode = WIFI_STA; // Default user mode
String _wifissidprefix =
String _wifissidprefix =
#ifdef ESP8266
"ESP"
#elif defined(ESP32)
@@ -799,16 +881,21 @@ 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
// currently disabled due to issues with caching, sometimes first scan is empty esp32 wifi not init yet race, or portals hit server nonstop flood
// The following are background wifi scanning optimizations
@@ -819,22 +906,22 @@ class WiFiManager
// async enables asyncronous scans, so they do not block anything
// the refresh button bypasses cache
// no aps found is problematic as scans are always going to want to run, leading to page load delays
//
//
// These settings really only make sense with _preloadwifiscan true
// but not limited to, we could run continuous background scans on various page hits, or xhr hits
// which would be better coupled with asyncscan
// atm preload is only done on root hit and startcp
//
//
// preload scanning causes AP to delay showing for users, but also caches and lets the cp load faster once its open
// my scan takes 7-10 seconds
public:
boolean _preloadwifiscan = false; // preload wifiscan if true
unsigned int _scancachetime = 30000; // ms cache time for preload scans
protected:
boolean _autoforcerescan = false; // automatically force rescan if scan networks is 0, ignoring cache
boolean _disableIpFields = false; // modify function of setShow_X_Fields(false), forces ip fields off instead of default show if set, eg. _staShowStaticFields=-1
String _wificountry = ""; // country code, @todo define in strings lang
@@ -848,7 +935,7 @@ protected:
void setupConfigPortal();
bool shutdownConfigPortal();
bool setupHostname(bool restart);
#ifdef NO_EXTRA_4K_HEAP
boolean _tryWPS = false; // try WPS on save failure, unsupported
void startWPS();
@@ -869,6 +956,23 @@ protected:
bool wifiConnectDefault();
bool wifiConnectNew(String ssid, String pass,bool connect = true);
void processStationController();
bool beginStationProfile(uint8_t slot);
void queueStationProfile(uint8_t slot);
void beginStationCycle(bool preferLastSuccessful);
void handleStationAttemptFailure(uint8_t status, const String& message);
void handleStationConnectionSuccess();
void completePortalStationAttempt(bool success, uint8_t status, const String& message);
void enterStationPortal();
bool hasUsableStationConnection() const;
bool isStationProfileEnabled(const WiFiManagerStationProfiles& profiles, uint8_t slot) const;
uint8_t configuredStationProfileCount(const WiFiManagerStationProfiles& profiles) const;
uint8_t chooseStationProfile(const WiFiManagerStationProfiles& profiles, bool preferLastSuccessful) const;
const WiFiManagerStationProfiles& stationProfilesForAttempt() const;
WiFiManagerStationProfiles& stationProfilesForAttempt();
bool validateStationProfiles(const WiFiManagerStationProfiles& profiles) const;
unsigned long stationAttemptTimeout() const;
uint8_t waitForConnectResult();
uint8_t waitForConnectResult(uint32_t timeout);
void updateConxResult(uint8_t status);
@@ -931,12 +1035,12 @@ protected:
#define WM_DISCONWORKAROUND
#endif
#else
#else
#define WM_NOCOUNTRY
#endif
#ifdef WM_NOCOUNTRY
#warning "ESP32 set country unavailable"
#warning "ESP32 set country unavailable"
#endif
@@ -1042,9 +1146,9 @@ protected:
// if we decide to support this, these checks will need to be replaced with something client aware to check if client origin is ap or web
// These state checks are critical and used for internal function checks
boolean webPortalActive = false;
boolean storeSTAmode = true; // option store persistent STA mode in connectwifi
boolean storeSTAmode = true; // option store persistent STA mode in connectwifi
int timer = 0; // timer for debug throttle for numclients, and portal timeout messages
// WiFiManagerParameter
int _paramsCount = 0;
int _max_params;
@@ -101,11 +101,11 @@ class WiFiManagerHandlers {
void appendVisibleScanResultsJson(String& json, const std::vector<const WiFiManager::WiFiScanNetwork*>& networks);
void applyWifiAndParamsFromRequest(AsyncWebServerRequest *request);
bool buildStationProfilesFromRequest(AsyncWebServerRequest *request, WiFiManagerStationProfiles& profiles);
void buildPlainStatusSummary(String& out);
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)}"
+83 -17
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>";}
@@ -465,6 +471,14 @@ function bindWifiViewEvents(){
if(p)p.type=this.checked?'text':'password';
});
}
var profileShowPass=document.querySelectorAll('.wm-showpass');
var profileIndex;
for(profileIndex=0;profileIndex<profileShowPass.length;profileIndex++){
profileShowPass[profileIndex].addEventListener('change',function(){
var target=$(this.getAttribute('data-target')||'');
if(target)target.type=this.checked?'text':'password';
});
}
var scanResults=$('wm-scan-results');
if(scanResults){
@@ -473,8 +487,8 @@ function bindWifiViewEvents(){
while(el && el !== scanResults){
if(el.tagName==='A' && el.getAttribute('data-ssid')){
ev.preventDefault();
var inp=$('wm-s');
var pass=$('wm-p');
var inp=$('wm-s0')||$('wm-s');
var pass=$('wm-p0')||$('wm-p');
if(inp)inp.value=el.getAttribute('data-ssid')||'';
if(pass){
if(pass.scrollIntoView)pass.scrollIntoView({behavior:'smooth',block:'center'});
@@ -491,6 +505,30 @@ function bindWifiViewEvents(){
}
}
function renderStationProfileFields(profiles){
var html='';
var i;
for(i=0;i<profiles.length;i++){
var p=profiles[i]||{};
var primary=i===0;
var title=primary?'Primary WiFi':'Fallback WiFi (optional)';
var hint=primary
? 'This network is tried first for a new connection.'
: 'Tried when the primary network is unavailable.';
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' 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' 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+='</form>';
}
return html;
}
function viewWifi(){
setView(navBar('wifi')+"<div class='wm-card'><p class='wm-lead'>Loading WiFi options…</p></div>");
api('/api/wifi/meta').then(function(res){
@@ -501,18 +539,28 @@ 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'>";
html+=renderFieldList(m.wifiFields||[]);
var wf=m.wifiFields||[];
var hasPass=false;
var i;
for(i=0;i<wf.length;i++){if(wf[i]&&wf[i].id==='p'){hasPass=true;break;}}
if(hasPass){
html+="<div class='wm-checkbox-row'><input type='checkbox' id='wm-showpass'/> <label for='wm-showpass'>Show password</label></div>";
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;
var i;
for(i=0;i<wf.length;i++){if(wf[i]&&wf[i].id==='p'){hasPass=true;break;}}
if(hasPass){
html+="<div class='wm-checkbox-row'><input type='checkbox' id='wm-showpass'/> <label for='wm-showpass'>Show password</label></div>";
}
}
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'>"+labelWithIcon('connect','Save and connect')+"</button></div></form>";
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='button' class='wm-btn wm-btn--secondary wm-btn--block' data-wm-wifi-action='save'>Save for later</button>";
}
html+="</div>";
html+="<div id='wm-wifi-msg'></div></div>";
setView(html);
bindWifiViewEvents();
@@ -595,9 +643,17 @@ function pollWifiConnectStatus(){
poll();
}
window.portalWifiSave=function(ev){
ev.preventDefault();
var fd=new FormData(document.getElementById('wm-wifi-form'));
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){
@@ -612,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>";
+553 -31
View File
@@ -27,6 +27,7 @@ uint8_t WiFiManager::_lastconxresulttmp = WL_IDLE_STATUS;
namespace {
constexpr size_t kMaxHostnameLength = 32;
constexpr uint8_t kSoftApStartMaxAttempts = 3;
constexpr unsigned long kStationProfileSwitchDelayMs = 2000UL; // ESP WiFi needs time to leave a failed association.
bool isValidHostnameChar(char c) {
return isAlphaNumeric(c) || c == '-';
@@ -55,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
/**
@@ -148,6 +168,7 @@ WiFiManager::WiFiManager() {
}
void WiFiManager::WiFiManagerInit(){
_portalBrand.title = WiFiManagerPortalText::progmem(kDefaultPortalTitle);
if (_logEnabled && (uint8_t)_runtimeMaxLevel >= (uint8_t)WiFiManagerLogLevel::Trace) {
debugPlatformInfo();
}
@@ -197,6 +218,465 @@ void WiFiManager::_end(){
if(_userpersistent) WiFi.persistent(true); // reenable persistent, there is no getter we rely on _userpersistent
}
void WiFiManager::setStationProfileStore(WiFiManagerStationProfileStore* store) {
_stationProfileStore = store;
_stationProfilesLoaded = false;
_stationCandidateActive = false;
_stationCandidateFromPortal = false;
_stationAttemptMask = 0;
_stationPendingSlot = WM_NO_STATION_PROFILE;
_stationNextAttemptAt = 0;
_stationStatus = wm_station_status_t();
}
bool WiFiManager::isStationProfileMode() const {
return _stationProfileStore != nullptr;
}
void WiFiManager::setStationRecoveryInterval(unsigned long intervalMs) {
if (intervalMs > 0) {
_stationRecoveryInterval = intervalMs;
}
}
const WiFiManagerStationProfiles& WiFiManager::getStationProfiles() const {
return _stationProfiles;
}
const WiFiManager::wm_station_status_t& WiFiManager::getStationStatus() const {
return _stationStatus;
}
bool WiFiManager::isStationProfileEnabled(const WiFiManagerStationProfiles& profiles, uint8_t slot) const {
return slot < WM_STATION_PROFILE_COUNT && profiles.slots[slot].enabled && profiles.slots[slot].ssid[0] != '\0';
}
uint8_t WiFiManager::configuredStationProfileCount(const WiFiManagerStationProfiles& profiles) const {
uint8_t count = 0;
for (uint8_t slot = 0; slot < WM_STATION_PROFILE_COUNT; ++slot) {
if (isStationProfileEnabled(profiles, slot)) {
++count;
}
}
return count;
}
bool WiFiManager::validateStationProfiles(const WiFiManagerStationProfiles& profiles) const {
if (profiles.preferredSlot >= WM_STATION_PROFILE_COUNT || !isStationProfileEnabled(profiles, 0)) {
return false;
}
for (uint8_t slot = 0; slot < WM_STATION_PROFILE_COUNT; ++slot) {
const WiFiManagerStationProfile& profile = profiles.slots[slot];
if (!profile.enabled) {
continue;
}
if (memchr(profile.ssid, '\0', sizeof(profile.ssid)) == nullptr ||
memchr(profile.password, '\0', sizeof(profile.password)) == nullptr) {
return false;
}
}
return profiles.lastSuccessfulSlot == WM_NO_STATION_PROFILE ||
isStationProfileEnabled(profiles, profiles.lastSuccessfulSlot);
}
uint8_t WiFiManager::chooseStationProfile(const WiFiManagerStationProfiles& profiles,
bool preferLastSuccessful) const {
if (preferLastSuccessful && isStationProfileEnabled(profiles, profiles.lastSuccessfulSlot)) {
return profiles.lastSuccessfulSlot;
}
if (isStationProfileEnabled(profiles, profiles.preferredSlot)) {
return profiles.preferredSlot;
}
for (uint8_t slot = 0; slot < WM_STATION_PROFILE_COUNT; ++slot) {
if (isStationProfileEnabled(profiles, slot)) {
return slot;
}
}
return WM_NO_STATION_PROFILE;
}
const WiFiManagerStationProfiles& WiFiManager::stationProfilesForAttempt() const {
return _stationCandidateActive ? _stationCandidate : _stationProfiles;
}
WiFiManagerStationProfiles& WiFiManager::stationProfilesForAttempt() {
return _stationCandidateActive ? _stationCandidate : _stationProfiles;
}
unsigned long WiFiManager::stationAttemptTimeout() const {
// The synchronous legacy connection path allows an unlimited timeout. A
// profile controller must stay bounded so it can advance to the fallback.
return _connectTimeout > 0 ? _connectTimeout : 15000UL;
}
bool WiFiManager::hasUsableStationConnection() const {
if (WiFi.status() != WL_CONNECTED) {
return false;
}
return WiFi.localIP() != IPAddress(0, 0, 0, 0);
}
bool WiFiManager::startStationConnection(char const *apName, char const *apPassword) {
if (!isStationProfileMode()) {
return false;
}
_begin();
_stationPortalApName = apName ? apName : getDefaultAPName();
_stationPortalApPassword = apPassword ? apPassword : "";
_stationCandidateActive = false;
_stationCandidateFromPortal = false;
_stationAttemptMask = 0;
_stationPendingSlot = WM_NO_STATION_PROFILE;
_stationNextAttemptAt = 0;
_stationStatus = wm_station_status_t();
_stationStatus.state = WM_STATION_LOADING;
_stationStatus.message = F("Loading WiFi profiles");
WiFiManagerStationProfiles loaded;
if (_stationProfileStore->load(loaded)) {
_stationProfiles = loaded;
} else {
_stationProfiles = WiFiManagerStationProfiles();
}
_stationProfilesLoaded = true;
_stationStatus.configuredProfiles = configuredStationProfileCount(_stationProfiles);
if (!validateStationProfiles(_stationProfiles)) {
_stationStatus.message = _stationStatus.configuredProfiles == 0
? F("No WiFi profiles configured")
: F("Stored WiFi profiles are invalid");
enterStationPortal();
return false;
}
beginStationCycle(true);
return true;
}
bool WiFiManager::startStationCandidate(const WiFiManagerStationProfiles& candidate) {
if (!validateStationProfiles(candidate)) {
_stationStatus.state = WM_STATION_IDLE;
_stationStatus.message = F("WiFi profile candidate is invalid");
return false;
}
if (!_stationProfilesLoaded && _stationProfileStore) {
WiFiManagerStationProfiles loaded;
_stationProfiles = _stationProfileStore->load(loaded)
? loaded
: WiFiManagerStationProfiles();
_stationProfilesLoaded = true;
}
_begin();
_stationCandidate = candidate;
_stationCandidateActive = true;
_stationCandidateFromPortal = configPortalActive;
_stationAttemptMask = 0;
_stationPendingSlot = WM_NO_STATION_PROFILE;
_stationNextAttemptAt = 0;
_stationStatus = wm_station_status_t();
_stationStatus.state = WM_STATION_LOADING;
_stationStatus.configuredProfiles = configuredStationProfileCount(candidate);
_stationStatus.message = F("WiFi profile candidate queued");
if (_stationCandidateFromPortal) {
_cpConnectStatus = WL_IDLE_STATUS;
_cpConnectStationIp = "";
_cpConnectState = wm_cp_connect_state_t::queued;
_cpConnectMessage = F("WiFi profile candidate queued");
emitPortalEvent(WM_EVENT_PORTAL_CONNECT_QUEUED);
}
beginStationCycle(false);
return true;
}
bool WiFiManager::startStationCandidate(const WiFiManagerStationProfiles& candidate,
char const *apName, char const *apPassword) {
_stationPortalApName = apName ? apName : getDefaultAPName();
_stationPortalApPassword = apPassword ? apPassword : "";
return startStationCandidate(candidate);
}
bool WiFiManager::saveStationProfiles(const WiFiManagerStationProfiles& profiles) {
if (!isStationProfileMode() || !validateStationProfiles(profiles)) {
return false;
}
if (!_stationProfileStore->save(profiles)) {
_stationStatus.storageSaveFailed = true;
_stationStatus.message = F("WiFi profiles could not be saved");
return false;
}
_stationProfiles = profiles;
_stationProfilesLoaded = true;
_stationStatus.configuredProfiles = configuredStationProfileCount(_stationProfiles);
_stationStatus.storageSaveFailed = false;
_stationStatus.message = F("WiFi profiles saved");
return true;
}
void WiFiManager::clearStationProfiles() {
if (_stationProfileStore && !_stationProfileStore->clear()) {
_stationStatus.storageSaveFailed = true;
_stationStatus.message = F("WiFi profiles could not be cleared");
return;
}
WiFi_Disconnect();
WiFi_eraseConfig();
_stationProfiles = WiFiManagerStationProfiles();
_stationCandidate = WiFiManagerStationProfiles();
_stationCandidateActive = false;
_stationCandidateFromPortal = false;
_stationProfilesLoaded = false;
_stationAttemptMask = 0;
_stationPendingSlot = WM_NO_STATION_PROFILE;
_stationNextAttemptAt = 0;
_stationEverConnected = false;
_stationStatus = wm_station_status_t();
_stationStatus.message = F("WiFi profiles cleared");
emitPortalEvent(WM_EVENT_STATION_PROFILES_CLEARED);
}
void WiFiManager::beginStationCycle(bool preferLastSuccessful) {
_stationAttemptMask = 0;
_stationPendingSlot = WM_NO_STATION_PROFILE;
_stationNextAttemptAt = 0;
const uint8_t first = chooseStationProfile(stationProfilesForAttempt(), preferLastSuccessful);
if (first == WM_NO_STATION_PROFILE) {
enterStationPortal();
return;
}
beginStationProfile(first);
}
bool WiFiManager::beginStationProfile(uint8_t slot) {
const WiFiManagerStationProfiles& profiles = stationProfilesForAttempt();
if (!isStationProfileEnabled(profiles, slot)) {
return false;
}
const WiFiManagerStationProfile& profile = profiles.slots[slot];
_stationPendingSlot = WM_NO_STATION_PROFILE;
_stationNextAttemptAt = 0;
_stationAttemptMask |= static_cast<uint8_t>(1U << slot);
_stationAttemptStartedAt = millis();
_stationStatus.state = WM_STATION_ATTEMPTING;
_stationStatus.attemptedSlot = slot;
_stationStatus.wifiStatus = WL_IDLE_STATUS;
_stationStatus.message = String(F("Connecting profile ")) + String(slot + 1);
emitPortalEvent(WM_EVENT_STATION_PROFILE_ATTEMPT);
WiFi.persistent(false);
WiFi.setAutoReconnect(false);
WiFi_enableSTA(true, false);
setSTAConfig();
if (_cleanConnect) {
WiFi_Disconnect();
}
WiFi.begin(
profile.ssid,
profile.hasPassword ? profile.password : nullptr);
return true;
}
void WiFiManager::queueStationProfile(uint8_t slot) {
_stationPendingSlot = slot;
_stationNextAttemptAt = millis() + kStationProfileSwitchDelayMs;
_stationStatus.state = WM_STATION_SWITCHING;
_stationStatus.message = String(F("Switching to profile ")) + String(slot + 1);
WiFi_Disconnect();
}
void WiFiManager::completePortalStationAttempt(bool success, uint8_t status, const String& message) {
if (!_stationCandidateFromPortal && !configPortalActive) {
return;
}
_cpConnectStatus = status;
_cpConnectMessage = message;
_cpConnectStationIp = success ? WiFi.localIP().toString() : "";
_cpConnectState = success ? wm_cp_connect_state_t::success : wm_cp_connect_state_t::failed;
if (success) {
if (_savewificallback != NULL) {
_savewificallback();
}
emitPortalEvent(WM_EVENT_PORTAL_CONNECT_SUCCESS);
if (_disableConfigPortal) {
shutdownConfigPortal();
}
} else {
updateConxResult(status);
emitPortalEvent(WM_EVENT_PORTAL_CONNECT_FAILED);
}
}
void WiFiManager::handleStationConnectionSuccess() {
const uint8_t slot = _stationStatus.attemptedSlot;
const bool wasCandidate = _stationCandidateActive;
const bool wasPortalCandidate = _stationCandidateFromPortal;
bool persisted = true;
if (wasCandidate) {
const WiFiManagerStationProfiles previous = _stationProfiles;
_stationCandidate.lastSuccessfulSlot = slot;
if (_stationProfileStore) {
persisted = _stationProfileStore->save(_stationCandidate);
}
if (persisted) {
_stationProfiles = _stationCandidate;
_stationProfilesLoaded = true;
} else {
_stationProfiles = previous;
}
_stationCandidateActive = false;
_stationCandidateFromPortal = false;
} else {
const bool changed = _stationProfiles.lastSuccessfulSlot != slot;
_stationProfiles.lastSuccessfulSlot = slot;
if (changed && _stationProfileStore) {
persisted = _stationProfileStore->save(_stationProfiles);
}
}
_stationEverConnected = true;
_stationStatus.state = WM_STATION_CONNECTED;
_stationStatus.activeSlot = slot;
_stationStatus.wifiStatus = WL_CONNECTED;
_stationStatus.lastConnectionWasCandidate = wasCandidate;
_stationStatus.storageSaveFailed = !persisted;
_stationStatus.message = persisted ? F("WiFi connected") : F("WiFi connected but profiles could not be saved");
emitPortalEvent(WM_EVENT_STATION_PROFILE_CONNECTED);
if (wasPortalCandidate) {
completePortalStationAttempt(persisted, persisted ? WL_CONNECTED : WL_CONNECT_FAILED,
_stationStatus.message);
}
}
void WiFiManager::enterStationPortal() {
_stationStatus.state = WM_STATION_PORTAL;
if (_stationStatus.message.length() == 0) {
_stationStatus.message = F("WiFi configuration required");
}
if (configPortalActive || !_enableConfigPortal) {
return;
}
String apName = _stationPortalApName.length() ? _stationPortalApName : getDefaultAPName();
startConfigPortal(apName.c_str(), _stationPortalApPassword.length() ? _stationPortalApPassword.c_str() : NULL);
}
void WiFiManager::handleStationAttemptFailure(uint8_t status, const String& message) {
_stationStatus.wifiStatus = status;
_stationStatus.message = message;
const WiFiManagerStationProfiles& profiles = stationProfilesForAttempt();
for (uint8_t slot = 0; slot < WM_STATION_PROFILE_COUNT; ++slot) {
const uint8_t bit = static_cast<uint8_t>(1U << slot);
if (isStationProfileEnabled(profiles, slot) && !(_stationAttemptMask & bit)) {
queueStationProfile(slot);
return;
}
}
const bool wasCandidate = _stationCandidateActive;
const bool wasPortalCandidate = _stationCandidateFromPortal;
if (wasCandidate) {
_stationCandidateActive = false;
_stationCandidateFromPortal = false;
_stationAttemptMask = 0;
_stationPendingSlot = WM_NO_STATION_PROFILE;
_stationNextAttemptAt = 0;
if (wasPortalCandidate) {
_stationStatus.state = WM_STATION_PORTAL;
completePortalStationAttempt(false, status, message);
emitPortalEvent(WM_EVENT_STATION_PROFILE_FAILED);
return;
}
if (validateStationProfiles(_stationProfiles)) {
_stationStatus.configuredProfiles = configuredStationProfileCount(_stationProfiles);
beginStationCycle(true);
return;
}
}
emitPortalEvent(WM_EVENT_STATION_PROFILE_FAILED);
if (_stationEverConnected) {
_stationStatus.state = WM_STATION_BACKOFF;
_stationBackoffStartedAt = millis();
_stationStatus.message = F("WiFi profiles unavailable; retrying shortly");
emitPortalEvent(WM_EVENT_STATION_BACKOFF);
} else {
enterStationPortal();
}
}
void WiFiManager::processStationController() {
if (!isStationProfileMode() && !_stationCandidateActive) {
return;
}
switch (_stationStatus.state) {
case WM_STATION_ATTEMPTING: {
const uint8_t status = WiFi.status();
_stationStatus.wifiStatus = status;
if (hasUsableStationConnection()) {
handleStationConnectionSuccess();
return;
}
if ((millis() - _stationAttemptStartedAt) >= stationAttemptTimeout()) {
handleStationAttemptFailure(WL_CONNECT_FAILED, F("WiFi connection timed out"));
}
return;
}
case WM_STATION_SWITCHING:
if (static_cast<long>(millis() - _stationNextAttemptAt) >= 0) {
const uint8_t nextSlot = _stationPendingSlot;
_stationPendingSlot = WM_NO_STATION_PROFILE;
_stationNextAttemptAt = 0;
if (nextSlot == WM_NO_STATION_PROFILE) {
handleStationAttemptFailure(WL_CONNECT_FAILED, F("WiFi profile switch lost its target"));
} else {
beginStationProfile(nextSlot);
}
}
return;
case WM_STATION_CONNECTED:
if (!hasUsableStationConnection()) {
emitPortalEvent(WM_EVENT_STATION_LINK_LOST);
_stationAttemptMask = 0;
_stationPendingSlot = WM_NO_STATION_PROFILE;
_stationNextAttemptAt = 0;
const uint8_t active = isStationProfileEnabled(_stationProfiles, _stationStatus.activeSlot)
? _stationStatus.activeSlot
: chooseStationProfile(_stationProfiles, true);
if (active == WM_NO_STATION_PROFILE) {
enterStationPortal();
} else {
beginStationProfile(active);
}
}
return;
case WM_STATION_BACKOFF:
if ((millis() - _stationBackoffStartedAt) >= _stationRecoveryInterval) {
beginStationCycle(true);
}
return;
default:
return;
}
}
boolean WiFiManager::autoConnect() {
String ssid = getDefaultAPName();
return autoConnect(ssid.c_str(), NULL);
@@ -388,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
@@ -656,7 +1126,10 @@ boolean WiFiManager::process(){
#endif
processScan();
processPortalConnect();
processStationController();
if (!_stationCandidateFromPortal) {
processPortalConnect();
}
if(webPortalActive || configPortalActive){
// if timed out or abort, break
@@ -813,7 +1286,8 @@ void WiFiManager::processPortalConnect() {
}
return;
}
if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL || status == WL_CONNECTION_LOST) {
if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL ||
status == WL_CONNECTION_LOST) {
failPortalConnect(status, getWLStatusString(status));
return;
}
@@ -2095,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) {
@@ -2123,6 +2655,7 @@ void WiFiManager::portalSetPageSetupVisible(bool visible) {
_portalPages.setupVisible = visible;
}
void WiFiManager::portalSetActionEraseVisible(bool visible) {
_portalActions.eraseVisible = visible;
}
@@ -2227,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
+183 -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);
@@ -783,6 +778,62 @@ void WiFiManagerHandlers::applyWifiAndParamsFromRequest(AsyncWebServerRequest *r
}
}
bool WiFiManagerHandlers::buildStationProfilesFromRequest(
AsyncWebServerRequest *request, WiFiManagerStationProfiles& profiles) {
profiles = _wm->_stationProfiles;
if (!_wm->validateStationProfiles(profiles)) {
profiles = WiFiManagerStationProfiles();
}
profiles.preferredSlot = 0;
for (uint8_t slot = 0; slot < WM_STATION_PROFILE_COUNT; ++slot) {
WiFiManagerStationProfile& profile = profiles.slots[slot];
const String ssidName = String(F("s")) + String(slot);
const String passwordName = String(F("p")) + String(slot);
const String clearName = String(F("clear")) + String(slot);
if (request->hasParam(ssidName.c_str(), true)) {
const String ssid = request->getParam(ssidName.c_str(), true)->value();
if (ssid.length() >= sizeof(profile.ssid)) {
return false;
}
memset(profile.ssid, 0, sizeof(profile.ssid));
memcpy(profile.ssid, ssid.c_str(), ssid.length());
profile.enabled = ssid.length() > 0;
if (!profile.enabled) {
profile.hasPassword = false;
memset(profile.password, 0, sizeof(profile.password));
if (profiles.lastSuccessfulSlot == slot) {
profiles.lastSuccessfulSlot = WM_NO_STATION_PROFILE;
}
}
}
if (request->hasParam(clearName.c_str(), true)) {
profile.hasPassword = false;
memset(profile.password, 0, sizeof(profile.password));
} else if (request->hasParam(passwordName.c_str(), true)) {
const String password = request->getParam(passwordName.c_str(), true)->value();
if (password.length() >= sizeof(profile.password)) {
return false;
}
// A blank password means "unchanged". Explicit clear is used for an
// open network so a browser never erases a stored secret by accident.
if (password.length() > 0) {
memset(profile.password, 0, sizeof(profile.password));
memcpy(profile.password, password.c_str(), password.length());
profile.hasPassword = true;
}
}
}
if (profiles.lastSuccessfulSlot != WM_NO_STATION_PROFILE &&
!_wm->isStationProfileEnabled(profiles, profiles.lastSuccessfulSlot)) {
profiles.lastSuccessfulSlot = WM_NO_STATION_PROFILE;
}
return _wm->validateStationProfiles(profiles);
}
void WiFiManagerHandlers::doParamSave(WiFiManager::WiFiManagerRequestArgs requestArgs){
if ( _wm->_presaveparamscallback != NULL) {
_wm->_presaveparamscallback();
@@ -1020,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\":");
@@ -1038,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\":\"");
@@ -1193,6 +1243,48 @@ void WiFiManagerHandlers::handleApiWifiScan(AsyncWebServerRequest *request) {
}
String WiFiManagerHandlers::buildApiWifiMetaJson() {
if (_wm->isStationProfileMode()) {
const WiFiManagerStationProfiles& profiles = _wm->getStationProfiles();
const WiFiManager::wm_station_status_t& station = _wm->getStationStatus();
String json = F("{\"profiles\":[");
for (uint8_t slot = 0; slot < WM_STATION_PROFILE_COUNT; ++slot) {
if (slot > 0) json += ',';
const WiFiManagerStationProfile& profile = profiles.slots[slot];
json += F("{\"slot\":\"");
json += slot == 0 ? F("primary") : F("fallback");
json += F("\",\"configured\":");
json += profile.enabled ? F("true") : F("false");
json += F(",\"ssid\":\"");
jsonAppendEscaped(json, profile.enabled ? String(profile.ssid) : String());
json += F("\",\"passwordSet\":");
json += profile.hasPassword ? F("true") : F("false");
json += F("}");
}
json += F("],\"activeSlot\":");
if (station.activeSlot == WM_NO_STATION_PROFILE) json += F("null");
else json += station.activeSlot == 0 ? F("\"primary\"") : F("\"fallback\"");
json += F(",\"state\":");
switch (station.state) {
case WiFiManager::WM_STATION_ATTEMPTING: json += F("\"connecting\""); break;
case WiFiManager::WM_STATION_CONNECTED: json += F("\"connected\""); break;
case WiFiManager::WM_STATION_BACKOFF: json += F("\"backoff\""); break;
case WiFiManager::WM_STATION_PORTAL: json += F("\"portal\""); break;
default: json += F("\"idle\""); break;
}
json += F(",\"wifiFields\":[],\"staticFields\":[");
bool first = true;
appendPortalJsonStaticFields(json, first);
json += F("],\"params\":[");
first = true;
if (_wm->_portalLayout.paramsOnWifiPage && _wm->getParametersCount() > 0) {
appendPortalJsonCustomParams(json, first);
}
json += F("],\"actions\":{\"canRefreshScan\":true,\"showBack\":");
json += _wm->_portalActions.backVisible ? F("true") : F("false");
json += F("}}");
return json;
}
String ssidPlaceholder = _wm->WiFi_SSID();
String passwordPlaceholder = "";
switch (_wm->_portalPasswordPlaceholderMode) {
@@ -1240,6 +1332,30 @@ void WiFiManagerHandlers::handleApiWifiSave(AsyncWebServerRequest *request) {
#endif
handleRequest(request);
applyWifiAndParamsFromRequest(request);
if (_wm->isStationProfileMode()) {
WiFiManagerStationProfiles candidate;
if (!buildStationProfilesFromRequest(request, candidate)) {
sendApiJson(request, 400, F("{\"ok\":false,\"message\":\"Primary WiFi is required and SSID/password lengths must be valid\"}"));
return;
}
const bool saveForLater = request->hasParam("stationAction", true) &&
request->getParam("stationAction", true)->value() == F("save");
if (saveForLater) {
if (!_wm->saveStationProfiles(candidate)) {
sendApiJson(request, 500, F("{\"ok\":false,\"message\":\"WiFi profiles could not be saved\"}"));
return;
}
sendApiJson(request, 200, F("{\"ok\":true,\"message\":\"WiFi profiles saved for later\"}"));
return;
}
if (!_wm->startStationCandidate(candidate)) {
sendApiJson(request, 400, F("{\"ok\":false,\"message\":\"WiFi profile candidate was rejected\"}"));
return;
}
sendApiJson(request, 202,
F("{\"ok\":true,\"message\":\"WiFi profiles accepted\",\"next\":{\"poll\":\"/api/wifi/connect-status\"}}"));
return;
}
_wm->queuePortalConnect(_wm->_ssid, _wm->_pass);
sendApiJson(
request, 202,
+10 -11
View File
@@ -1,6 +1,6 @@
{
"name": "WiFiManager",
"version": "3.0.5",
"version": "3.1.0",
"keywords": [
"wifi",
"wi-fi",
@@ -40,31 +40,30 @@
],
"dependencies": [
{
"owner": "ESP32Async",
"owner": "esp32async",
"name": "ESPAsyncWebServer",
"version": "3.9.1"
},
{
"owner": "ESP32Async",
"owner": "esp32async",
"name": "ESPAsyncTCP",
"version": "2.0.0",
"version": "^2.0.0",
"platforms": "espressif8266"
},
{
"owner": "ESP32Async",
"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": {
"includeDir": "lib/WiFiManager/include",
"srcDir": "lib/WiFiManager/src",
"extraScript": "tools/platformio/dependencies.py"
"srcDir": "lib/WiFiManager/src"
},
"export": {
"include": [
@@ -72,8 +71,8 @@
"lib/WiFiManager/src",
"LICENSE",
"README.md",
"library.json",
"tools"
"CHANGELOG.md",
"library.json"
]
},
"$schema": "https://raw.githubusercontent.com/platformio/platformio-core/develop/platformio/assets/schema/library.json",
+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]
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
failed=0
link_pattern='\]\(([^ )]+)'
while IFS= read -r file; do
in_fence=false
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ ^[[:space:]]*\`\`\` ]]; then
[[ "$in_fence" == true ]] && in_fence=false || in_fence=true
continue
fi
[[ "$in_fence" == true ]] && continue
remainder="$line"
while [[ "$remainder" =~ $link_pattern ]]; do
target="${BASH_REMATCH[1]}"
remainder="${remainder#*]($target)}"
case "$target" in
\#*|http://*|https://*|mailto:*|tel:*) continue ;;
esac
target="${target%%#*}"
[[ -z "$target" ]] && continue
if [[ "$target" == /* ]]; then
candidate="$root/${target#/}"
else
candidate="$(dirname "$file")/$target"
fi
if [[ ! -e "$candidate" ]]; then
printf 'Broken local Markdown link: %s -> %s\n' "${file#$root/}" "$target" >&2
failed=1
fi
done
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_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
fi
done
if [[ "$failed" -ne 0 ]]; then
exit 1
fi
echo "WiFiManager documentation checks passed"
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 --platform esp8266|esp32" >&2
exit 2
}
[[ "${1:-}" == "--platform" ]] || usage
platform="${2:-}"
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
[[ $# -eq 2 ]] || usage
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
pio run -d "$root/test/compile-project" -e "$platform"
echo "WiFiManager consumer compile check passed for $platform"
+6
View File
@@ -19,6 +19,11 @@ if [[ "$manifest_version" != "$version" ]]; then
exit 1
fi
grep -q "^## $version$" "$root/CHANGELOG.md" || {
echo "CHANGELOG.md has no $version heading" >&2
exit 1
}
if [[ -f "$root/library.properties" ]]; then
properties_version="$(sed -n 's/^version=//p' "$root/library.properties" | head -n 1)"
if [[ "$properties_version" != "$version" ]]; then
@@ -27,6 +32,7 @@ if [[ -f "$root/library.properties" ]]; then
fi
fi
git -C "$root" diff --check
package_dir="$(mktemp -d)"
trap 'rm -rf "$package_dir"' EXIT
pio pkg pack "$root" --output "$package_dir/package.tar.gz" >/dev/null
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
tag="${1:-}"
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "Usage: $0 vMAJOR.MINOR.PATCH" >&2
exit 2
}
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version="${tag#v}"
notes="$(mktemp)"
trap 'rm -f "$notes"' EXIT
awk -v heading="## ${version}" '
$0 == heading { found = 1; next }
found && /^## / { exit }
found { print }
END { if (!found) exit 1 }
' "$root/CHANGELOG.md" > "$notes" || {
echo "CHANGELOG.md has no ${version} section" >&2
exit 1
}
if [[ ! -s "$notes" ]]; then
echo "No release notes found for $tag in CHANGELOG.md" >&2
exit 1
fi
printf '%s\n\n' "# WiFiManager $tag"
cat "$notes"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 compile --platform esp8266|esp32" >&2
exit 2
}
[[ "${1:-}" == "compile" && "${2:-}" == "--platform" && $# -eq 3 ]] || usage
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"
+4 -5
View File
@@ -3,10 +3,9 @@ default_envs = esp8266
[common]
framework = arduino
lib_ldf_mode = chain
lib_ldf_mode = deep+
lib_deps =
WiFiManager=symlink://../..
ESP32Async/ESPAsyncWebServer@3.9.1
[env:esp8266]
extends = common
@@ -18,16 +17,16 @@ build_flags =
-DWM_LOG_LEVEL=3
lib_deps =
${common.lib_deps}
ESP32Async/ESPAsyncTCP@2.0.0
[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}
ESP32Async/AsyncTCP@3.4.9
+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");
}
-24
View File
@@ -1,24 +0,0 @@
"""Expose WiFiManager's async dependencies to PlatformIO's nested library builds."""
from __future__ import print_function
import glob
import os
from SCons.Script import Import
Import("env")
def append_dependency_include(package_prefix):
"""Add the installed package's source directory when PlatformIO has it."""
package_root = env.subst("$PROJECT_LIBDEPS_DIR")
candidates = glob.glob(os.path.join(package_root, package_prefix + "*", "src"))
if candidates:
env.Append(CPPPATH=[candidates[0]])
append_dependency_include("ESPAsyncWebServer")
if env.subst("$PIOPLATFORM") == "espressif8266":
append_dependency_include("ESPAsyncTCP")
elif env.subst("$PIOPLATFORM") == "espressif32":
append_dependency_include("AsyncTCP")