Compare commits

...
2 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
35 changed files with 1766 additions and 493 deletions
+15 -1
View File
@@ -2,12 +2,26 @@ name: Build
on: on:
push: push:
branches:
- device-framework
tags:
- "v*"
pull_request: pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: permissions:
contents: read contents: read
jobs: jobs:
documentation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./scripts/check-docs.sh
compile-tests: compile-tests:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
@@ -20,4 +34,4 @@ jobs:
with: with:
python-version: '3.11' python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19 - 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: with:
python-version: '3.11' python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19 - 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: ./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: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
+6
View File
@@ -1,5 +1,11 @@
# Changelog # 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 ## 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. - 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.
+45 -223
View File
@@ -1,18 +1,15 @@
# WiFiManager # WiFiManager
This repository is a **breaking fork** of upstream [`tzapu/WiFiManager`](https://github.com/tzapu/WiFiManager). 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.
It is not a drop-in replacement for upstream behavior, APIs, templates, or portal customization patterns.
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) - **Single-shell portal:** one responsive SPA for WiFi, parameters, information, actions, and firmware update flow.
**Upstream:** [tzapu/WiFiManager](https://github.com/tzapu/WiFiManager) - **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 ## Try it
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.
```cpp ```cpp
#include <Arduino.h> #include <Arduino.h>
@@ -22,247 +19,72 @@ WiFiManager wifi;
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
wifi.setConfigPortalTimeout(180); // seconds; 0 leaves it open wifi.setConfigPortalTimeout(180);
if (!wifi.autoConnect("Device Setup", "change-me")) { if (!wifi.autoConnect("Device Setup", "change-me")) {
ESP.restart(); ESP.restart();
} }
} }
void loop() { void loop() {}
// Normal application work after WiFi is connected.
}
``` ```
The AP password must meet the Wi-Fi password requirements. For applications 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.
built on DeviceFramework, configure `setConfigDevicePassword(...)` instead:
DeviceFramework passes the same validated password to WiFiManager, Arduino OTA,
HTTP Basic authentication, and WebSerial.
### Brand it
## 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
```cpp ```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"); const WiFiManagerPortalConfig kPortalUI = {
wm.portalSetContextIdentityText("Solar Battery Monitor"); WiFiManagerPortalText::progmem(kTitle),
wm.portalSetBrandHomeIntro( WiFiManagerPortalText::progmem(kBrand),
"Connect your monitor to WiFi, then review battery and inverter settings." {}, {}, {},
); {
wm.portalSetBrandLogoSvg( {}, {}, {}, {}, {},
"<svg viewBox='0 0 24 24' aria-hidden='true'>" WiFiManagerPortalText::progmem(kAccent),
"<path d='M12 2L4 12h5l-1 10 8-10h-5z'></path>" {}, {}, {}, {}, {},
"</svg>" 10, 0,
); },
};
wm.portalSetPageInfoVisible(true); void setup() {
wm.portalSetPageUpdateVisible(false); wifi.setPortalConfig(kPortalUI); // before the portal starts
wm.portalSetActionEraseVisible(false); wifi.autoConnect("Device Setup");
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/"
} }
``` ```
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. ## Explore the portal
- If the portal HTTP server is not on port `80`, `redirectUrl` includes the active port.
- On success, WiFiManager keeps the portal alive briefly so the client can read the success payload and navigate to the new device address before the captive AP is shut down.
- This improves the handoff on typical home networks, but it is not a universal guarantee: client captive-portal helpers, browser behavior, DHCP timing, and network isolation can still affect whether the redirect completes automatically.
## Dependencies | 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**. ## Install
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. The package manifest resolves DFTE, the asynchronous web
server, and the platform-specific TCP library automatically:
```ini ```ini
[common] [common]
lib_deps = lib_deps =
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.0.6 WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.1.0
[env:esp8266]
extends = common
platform = espressif8266
board = d1_mini
framework = arduino
[env:esp32]
extends = common
platform = espressif32
board = esp32dev
framework = arduino
``` ```
The text after `#` is a Git ref. PlatformIO clones the repository and checks 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.
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:
```ini ## Development and releases
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:
```bash ```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 ./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag
``` ```
Push the branch and tag. GitHub Actions validates the PlatformIO package again 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.
and creates the GitHub Release from that tag.
## AI Assistance Notice See the [documentation index](docs/README.md), [release history](CHANGELOG.md), and [licence](LICENSE).
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).
+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.h
* *
* WiFiManager, a library for the ESP8266/Arduino platform * WiFiManager, a library for the ESP8266/Arduino platform
* for configuration of WiFi credentials using a Captive Portal * for configuration of WiFi credentials using a Captive Portal
* *
* @author Creator tzapu * @author Creator tzapu
* @author tablatronix * @author tablatronix
* @author alexhopeoconnor * @author alexhopeoconnor
@@ -22,6 +22,7 @@
#include <vector> #include <vector>
#include <functional> #include <functional>
#include <string.h>
#include "WiFiManagerParameter.h" #include "WiFiManagerParameter.h"
#include "WiFiManagerLogLevel.h" #include "WiFiManagerLogLevel.h"
@@ -39,13 +40,13 @@
// #define WM_DFTE_LOGGING // opt-in: bridge DFTE logging into WiFiManager (see README) // #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_MDNS // includes MDNS, also set MDNS with sethostname
// #define WM_FIXERASECONFIG // use erase flash fix // #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 WM_RTC // esp32 info page will include reset reasons
// #define WIFI_MANAGER_OVERRIDE_STRINGS // build flag for using own strings include // #define WIFI_MANAGER_OVERRIDE_STRINGS // build flag for using own strings include
#ifdef ARDUINO_ESP8266_RELEASE_2_3_0 #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 // @todo check failing on platform = espressif8266@1.7.3
#define WM_NOASYNC // esp8266 no async scan wifi #define WM_NOASYNC // esp8266 no async scan wifi
#define WM_NOCOUNTRY // esp8266 no country #define WM_NOCOUNTRY // esp8266 no country
@@ -91,16 +92,16 @@
#include <ESP8266mDNS.h> #include <ESP8266mDNS.h>
#endif #endif
#define WIFI_getChipId() ESP.getChipId() #define WIFI_getChipId() ESP.getChipId()
#define WM_WIFIOPEN ENC_TYPE_NONE #define WM_WIFIOPEN ENC_TYPE_NONE
#elif defined(ESP32) #elif defined(ESP32)
#include <WiFi.h> #include <WiFi.h>
#include <esp_wifi.h> #include <esp_wifi.h>
#include <Update.h> #include <Update.h>
#include <AsyncTCP.h> #include <AsyncTCP.h>
#define WIFI_getChipId() (uint32_t)ESP.getEfuseMac() #define WIFI_getChipId() (uint32_t)ESP.getEfuseMac()
#define WM_WIFIOPEN WIFI_AUTH_OPEN #define WM_WIFIOPEN WIFI_AUTH_OPEN
@@ -141,13 +142,46 @@
#include <string> #include <string>
// Include utility functions // Include utility functions
#include "WiFiManagerUtils.h" #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 // prep string concat vars
#define WM_STRING2(x) #x #define WM_STRING2(x) #x
#define WM_STRING(x) WM_STRING2(x) #define WM_STRING(x) WM_STRING2(x)
// WiFiManager version // 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> // #include <esp_idf_version.h>
#ifdef ESP_IDF_VERSION #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_MINOR = " WM_STRING(ESP_IDF_VERSION_MINOR)
// #pragma message "ESP_IDF_VERSION_PATCH = " WM_STRING(ESP_IDF_VERSION_PATCH) // #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) #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" #define VER_IDF_STR "Unknown"
#endif #endif
@@ -184,7 +218,7 @@ const char WM_VERSION_STR[] PROGMEM = "v2.0.19";
#define VER_ARDUINO_STR "Unknown" #define VER_ARDUINO_STR "Unknown"
#endif #endif
#endif #endif
#else #else
#define VER_ARDUINO_STR "Unknown" #define VER_ARDUINO_STR "Unknown"
#endif #endif
@@ -232,10 +266,11 @@ struct PortalHomeCard {
}; };
struct PortalBrandState { struct PortalBrandState {
String title = "WiFiManager"; WiFiManagerPortalText title;
String identityTextOverride; WiFiManagerPortalText identityTextOverride;
String homeIntro; WiFiManagerPortalText homeIntro;
String logoSvg; WiFiManagerPortalAsset logo;
WiFiManagerPortalText logoAltText;
}; };
struct PortalPageState { struct PortalPageState {
@@ -257,12 +292,6 @@ struct PortalLayoutState {
bool paramsOnWifiPage = true; bool paramsOnWifiPage = true;
}; };
struct PortalAssetState {
String appendedCss;
String overriddenCss;
String appendedJs;
};
struct PortalStructuredExtrasState { struct PortalStructuredExtrasState {
std::vector<PortalInfoSection> infoSections; std::vector<PortalInfoSection> infoSections;
std::vector<PortalHomeCard> homeCards; std::vector<PortalHomeCard> homeCards;
@@ -327,6 +356,27 @@ class WiFiManager
WM_CP_CONNECT_FAILED, 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. */ /** Optional notification hook; prefer getters for consumers. */
enum wm_event_t : uint8_t { enum wm_event_t : uint8_t {
WM_EVENT_PORTAL_STARTED = 0, WM_EVENT_PORTAL_STARTED = 0,
@@ -334,10 +384,16 @@ class WiFiManager
WM_EVENT_PORTAL_CONNECT_QUEUED, WM_EVENT_PORTAL_CONNECT_QUEUED,
WM_EVENT_PORTAL_CONNECT_START, WM_EVENT_PORTAL_CONNECT_START,
WM_EVENT_PORTAL_CONNECT_SUCCESS, 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)>; using WiFiManagerEventCallback = std::function<void(wm_event_t)>;
WiFiManager(Print& consolePort); WiFiManager(Print& consolePort);
WiFiManager(); WiFiManager();
~WiFiManager(); ~WiFiManager();
@@ -347,14 +403,28 @@ class WiFiManager
boolean autoConnect(); boolean autoConnect();
boolean autoConnect(char const *apName, char const *apPassword = NULL); 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 //manually start the config portal, autoconnect does this automatically on connect failure
void startConfigPortal(); // auto generates apname void startConfigPortal(); // auto generates apname
void startConfigPortal(char const *apName, char const *apPassword = NULL); void startConfigPortal(char const *apName, char const *apPassword = NULL);
//manually stop the config portal - immediately shuts down the portal //manually stop the config portal - immediately shuts down the portal
void stopConfigPortal(); 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(); void startWebPortal();
//manually stop the web portal if started manually //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 // sets number of retries for autoconnect, force retry after wait failure exit
void setConnectRetries(uint8_t numRetries); // default 1 void setConnectRetries(uint8_t numRetries); // default 1
//sets timeout for which to attempt connecting on saves, useful if there are bugs in esp waitforconnectloop //sets timeout for which to attempt connecting on saves, useful if there are bugs in esp waitforconnectloop
void setSaveConnectTimeout(unsigned long seconds); void setSaveConnectTimeout(unsigned long seconds);
// lets you disable automatically connecting after save from webportal // lets you disable automatically connecting after save from webportal
void setSaveConnect(bool connect = true); void setSaveConnect(bool connect = true);
void setLogEnabled(boolean enabled); void setLogEnabled(boolean enabled);
void setLogPrefix(String prefix); void setLogPrefix(String prefix);
void setLogOutput(boolean enabled, WiFiManagerLogLevel maxLevel); 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 //set min quality percentage to include in scan, defaults to 8% if not specified
void setMinimumSignalQuality(int quality = 8); void setMinimumSignalQuality(int quality = 8);
//sets a custom ip /gateway /subnet configuration //sets a custom ip /gateway /subnet configuration
void setAPStaticIPConfig(IPAddress ip, IPAddress gw, IPAddress sn); void setAPStaticIPConfig(IPAddress ip, IPAddress gw, IPAddress sn);
//sets config for a static IP //sets config for a static IP
void setSTAStaticIPConfig(IPAddress ip, IPAddress gw, IPAddress sn); void setSTAStaticIPConfig(IPAddress ip, IPAddress gw, IPAddress sn);
//sets config for a static IP with DNS //sets config for a static IP with DNS
void setSTAStaticIPConfig(IPAddress ip, IPAddress gw, IPAddress sn, IPAddress 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. //if this is set, it will exit after config, even if connection is unsuccessful.
void setBreakAfterConfig(boolean shouldBreak); void setBreakAfterConfig(boolean shouldBreak);
//if this is true, remove duplicated Access Points - defaut true //if this is true, remove duplicated Access Points - defaut true
void setRemoveDuplicateAPs(boolean removeDuplicates); void setRemoveDuplicateAPs(boolean removeDuplicates);
//setter for ESP wifi.persistent so we can remember it and restore user preference, as WIFi._persistent is protected //setter for ESP wifi.persistent so we can remember it and restore user preference, as WIFi._persistent is protected
void setRestorePersistent(boolean persistent); void setRestorePersistent(boolean persistent);
//if true, always show static net inputs, IP, subnet, gateway, else only show if set via setSTAStaticIPConfig //if true, always show static net inputs, IP, subnet, gateway, else only show if set via setSTAStaticIPConfig
void setShowStaticFields(boolean alwaysShow); void setShowStaticFields(boolean alwaysShow);
//if true, always show static dns, esle only show if set via setSTAStaticIPConfig //if true, always show static dns, esle only show if set via setSTAStaticIPConfig
void setShowDnsFields(boolean alwaysShow); void setShowDnsFields(boolean alwaysShow);
//if false, timeout captive portal even if a STA client connected to softAP (false), suggest disabling if captiveportal is open //if false, timeout captive portal even if a STA client connected to softAP (false), suggest disabling if captiveportal is open
void setAPClientCheck(boolean enabled); 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); void setWebPortalClientCheck(boolean enabled);
// if true, enable autoreconnecting // if true, enable autoreconnecting
void setWiFiAutoReconnect(boolean enabled); void setWiFiAutoReconnect(boolean enabled);
// if true, wifiscan will show percentage instead of quality icons, until we have better templating // if true, wifiscan will show percentage instead of quality icons, until we have better templating
void setScanDispPerc(boolean enabled); void setScanDispPerc(boolean enabled);
// if true (default) then start the config portal from autoConnect if connection failed // if true (default) then start the config portal from autoConnect if connection failed
void setEnableConfigPortal(boolean enable); void setEnableConfigPortal(boolean enable);
@@ -513,18 +583,17 @@ class WiFiManager
// set ap channel // set ap channel
void setWiFiAPChannel(int32_t channel); void setWiFiAPChannel(int32_t channel);
// set ap hidden // set ap hidden
void setWiFiAPHidden(bool hidden); // default false void setWiFiAPHidden(bool hidden); // default false
// clean connect, always disconnect before connecting // clean connect, always disconnect before connecting
void setCleanConnect(bool enable); // default false void setCleanConnect(bool enable); // default false
// ---- Portal (all customization entry points use the portal* prefix) ---- // ---- Portal presentation ----
void portalSetBrandTitle(const String& title); // Apply this before a portal is started. The portal response model is
void portalSetContextIdentityText(const String& identityText); // immutable while active so async responses never observe partial UI state.
void portalSetBrandHomeIntro(const String& text); bool setPortalConfig(const WiFiManagerPortalConfig& config);
void portalSetBrandLogoSvg(const String& svgMarkup);
void portalSetPageInfoVisible(bool visible); void portalSetPageInfoVisible(bool visible);
void portalSetPageUpdateVisible(bool visible); void portalSetPageUpdateVisible(bool visible);
@@ -557,16 +626,12 @@ class WiFiManager
void portalAddHomeCard(const PortalHomeCard& card); void portalAddHomeCard(const PortalHomeCard& card);
void portalClearHomeCards(); 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 // get last connection result, including autoconnect and portal credential-save attempts
uint8_t getLastConxResult(); uint8_t getLastConxResult();
// get a status as string // get a status as string
String getWLStatusString(uint8_t status); String getWLStatusString(uint8_t status);
String getWLStatusString(); String getWLStatusString();
// get wifi mode as string // get wifi mode as string
String getModeString(uint8_t mode); String getModeString(uint8_t mode);
@@ -574,7 +639,7 @@ class WiFiManager
// check if the module has a saved ap to connect to // check if the module has a saved ap to connect to
bool getWiFiIsSaved(); 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); String getWiFiPass(bool persistent = true);
// helper to get saved ssid, if persistent get stored, else get current if connected // helper to get saved ssid, if persistent get stored, else get current if connected
@@ -588,17 +653,17 @@ class WiFiManager
// helper for html // helper for html
String htmlEntities(String str, bool whitespace = false); String htmlEntities(String str, bool whitespace = false);
// set the country code for wifi settings, CN // set the country code for wifi settings, CN
void setCountry(String cc); void setCountry(String cc);
// get default ap esp uses , esp_chipid etc // get default ap esp uses , esp_chipid etc
String getDefaultAPName(); String getDefaultAPName();
// set the WiFi SSID prefix for default AP name, default platform-specific (ESP/ESP32/WM) // set the WiFi SSID prefix for default AP name, default platform-specific (ESP/ESP32/WM)
void setWiFiSSIDPrefix(String prefix); void setWiFiSSIDPrefix(String prefix);
// set port of webserver, 80 // set port of webserver, 80
void setHttpPort(uint16_t port); void setHttpPort(uint16_t port);
@@ -615,7 +680,7 @@ class WiFiManager
uint8_t getConfigPortalConnectStatus() const; uint8_t getConfigPortalConnectStatus() const;
String getConfigPortalConnectMessage() const; String getConfigPortalConnectMessage() const;
void setEventCallback(WiFiManagerEventCallback cb); void setEventCallback(WiFiManagerEventCallback cb);
// check if web portal is active (true) // check if web portal is active (true)
bool getWebPortalActive(); bool getWebPortalActive();
@@ -624,10 +689,10 @@ class WiFiManager
// get hostname helper // get hostname helper
String getWiFiHostname(); String getWiFiHostname();
// get server instance (for testing) // get server instance (for testing)
AsyncWebServer* getServer(); AsyncWebServer* getServer();
// get DNS server instance (for testing) // get DNS server instance (for testing)
DNSServer* getDNSServer(); DNSServer* getDNSServer();
@@ -636,7 +701,7 @@ class WiFiManager
class WiFiManagerRequestArgs { class WiFiManagerRequestArgs {
public: public:
std::unordered_map<std::string, std::string> args; std::unordered_map<std::string, std::string> args;
// Constructor - builds from AsyncWebServerRequest // Constructor - builds from AsyncWebServerRequest
WiFiManagerRequestArgs(AsyncWebServerRequest* request) { WiFiManagerRequestArgs(AsyncWebServerRequest* request) {
if (request) { if (request) {
@@ -647,19 +712,19 @@ class WiFiManager
} }
} }
} }
// Default constructor for tests and manually assembled argument sets // Default constructor for tests and manually assembled argument sets
WiFiManagerRequestArgs() {} WiFiManagerRequestArgs() {}
// Check if argument exists // Check if argument exists
bool hasArg(const char* name) const { bool hasArg(const char* name) const {
return args.find(std::string(name)) != args.end(); return args.find(std::string(name)) != args.end();
} }
bool hasArg(const String& name) const { bool hasArg(const String& name) const {
return hasArg(name.c_str()); return hasArg(name.c_str());
} }
// Get argument value as String // Get argument value as String
String getArg(const char* name, const String& defaultValue = "") const { String getArg(const char* name, const String& defaultValue = "") const {
auto it = args.find(std::string(name)); auto it = args.find(std::string(name));
@@ -668,29 +733,29 @@ class WiFiManager
} }
return defaultValue; return defaultValue;
} }
String getArg(const String& name, const String& defaultValue = "") const { String getArg(const String& name, const String& defaultValue = "") const {
return getArg(name.c_str(), defaultValue); return getArg(name.c_str(), defaultValue);
} }
// Type conversion helpers // Type conversion helpers
int getArgAsInt(const char* name, int defaultValue = 0) const { int getArgAsInt(const char* name, int defaultValue = 0) const {
String value = getArg(name); String value = getArg(name);
return value.length() > 0 ? value.toInt() : defaultValue; return value.length() > 0 ? value.toInt() : defaultValue;
} }
float getArgAsFloat(const char* name, float defaultValue = 0.0f) const { float getArgAsFloat(const char* name, float defaultValue = 0.0f) const {
String value = getArg(name); String value = getArg(name);
return value.length() > 0 ? value.toFloat() : defaultValue; return value.length() > 0 ? value.toFloat() : defaultValue;
} }
bool getArgAsBool(const char* name, bool defaultValue = false) const { bool getArgAsBool(const char* name, bool defaultValue = false) const {
String value = getArg(name); String value = getArg(name);
if (value.length() == 0) return defaultValue; if (value.length() == 0) return defaultValue;
return value == "1" || value.equalsIgnoreCase("true") || return value == "1" || value.equalsIgnoreCase("true") ||
value.equalsIgnoreCase("on") || value.equalsIgnoreCase("yes"); value.equalsIgnoreCase("on") || value.equalsIgnoreCase("yes");
} }
size_t count() const { size_t count() const {
return args.size(); return args.size();
} }
@@ -723,7 +788,7 @@ class WiFiManager
WiFiScanRuntimeState _scan; WiFiScanRuntimeState _scan;
std::vector<WiFiScanNetwork> _scanResultsCache; std::vector<WiFiScanNetwork> _scanResultsCache;
bool _scanLifecycleBlocked = false; bool _scanLifecycleBlocked = false;
// async reboot/abort scheduling // async reboot/abort scheduling
bool _rebootScheduled = false; // flag for scheduled reboot bool _rebootScheduled = false; // flag for scheduled reboot
unsigned long _rebootTime = 0; // ms when reboot should occur unsigned long _rebootTime = 0; // ms when reboot should occur
@@ -742,13 +807,30 @@ class WiFiManager
String _defaultssid = ""; // preload ssid String _defaultssid = ""; // preload ssid
String _defaultpass = ""; // preload pass 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 // options flags
unsigned long _configPortalTimeout = 0; // ms close config portal loop if set (depending on _cp/webClientCheck options) 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 _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 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 WiFiMode_t _usermode = WIFI_STA; // Default user mode
String _wifissidprefix = String _wifissidprefix =
#ifdef ESP8266 #ifdef ESP8266
"ESP" "ESP"
#elif defined(ESP32) #elif defined(ESP32)
@@ -799,16 +881,21 @@ class WiFiManager
boolean _disableConfigPortal = true; // FOR autoconnect - stop config portal if cp wifi save boolean _disableConfigPortal = true; // FOR autoconnect - stop config portal if cp wifi save
String _hostname = ""; // hostname for esp8266 for dhcp, and or MDNS 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; PortalBrandState _portalBrand;
WiFiManagerPortalTheme _portalTheme;
String _portalThemeStyle;
PortalPageState _portalPages; PortalPageState _portalPages;
PortalActionState _portalActions; PortalActionState _portalActions;
PortalLayoutState _portalLayout; PortalLayoutState _portalLayout;
PortalAssetState _portalAssets;
PortalStructuredExtrasState _portalStructured; PortalStructuredExtrasState _portalStructured;
bool canChangePortalPresentation() const;
bool isPortalThemeValid(const WiFiManagerPortalTheme& theme) const;
void rebuildPortalThemeStyle();
// internal options // internal options
// wifiscan notes // 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 // 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 // The following are background wifi scanning optimizations
@@ -819,22 +906,22 @@ class WiFiManager
// async enables asyncronous scans, so they do not block anything // async enables asyncronous scans, so they do not block anything
// the refresh button bypasses cache // the refresh button bypasses cache
// no aps found is problematic as scans are always going to want to run, leading to page load delays // 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 // 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 // but not limited to, we could run continuous background scans on various page hits, or xhr hits
// which would be better coupled with asyncscan // which would be better coupled with asyncscan
// atm preload is only done on root hit and startcp // 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 // 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 // my scan takes 7-10 seconds
public: public:
boolean _preloadwifiscan = false; // preload wifiscan if true boolean _preloadwifiscan = false; // preload wifiscan if true
unsigned int _scancachetime = 30000; // ms cache time for preload scans unsigned int _scancachetime = 30000; // ms cache time for preload scans
protected: protected:
boolean _autoforcerescan = false; // automatically force rescan if scan networks is 0, ignoring cache 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 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 String _wificountry = ""; // country code, @todo define in strings lang
@@ -848,7 +935,7 @@ protected:
void setupConfigPortal(); void setupConfigPortal();
bool shutdownConfigPortal(); bool shutdownConfigPortal();
bool setupHostname(bool restart); bool setupHostname(bool restart);
#ifdef NO_EXTRA_4K_HEAP #ifdef NO_EXTRA_4K_HEAP
boolean _tryWPS = false; // try WPS on save failure, unsupported boolean _tryWPS = false; // try WPS on save failure, unsupported
void startWPS(); void startWPS();
@@ -869,6 +956,23 @@ protected:
bool wifiConnectDefault(); bool wifiConnectDefault();
bool wifiConnectNew(String ssid, String pass,bool connect = true); 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();
uint8_t waitForConnectResult(uint32_t timeout); uint8_t waitForConnectResult(uint32_t timeout);
void updateConxResult(uint8_t status); void updateConxResult(uint8_t status);
@@ -931,12 +1035,12 @@ protected:
#define WM_DISCONWORKAROUND #define WM_DISCONWORKAROUND
#endif #endif
#else #else
#define WM_NOCOUNTRY #define WM_NOCOUNTRY
#endif #endif
#ifdef WM_NOCOUNTRY #ifdef WM_NOCOUNTRY
#warning "ESP32 set country unavailable" #warning "ESP32 set country unavailable"
#endif #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 // 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 // These state checks are critical and used for internal function checks
boolean webPortalActive = false; 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 int timer = 0; // timer for debug throttle for numclients, and portal timeout messages
// WiFiManagerParameter // WiFiManagerParameter
int _paramsCount = 0; int _paramsCount = 0;
int _max_params; int _max_params;
@@ -101,11 +101,11 @@ class WiFiManagerHandlers {
void appendVisibleScanResultsJson(String& json, const std::vector<const WiFiManager::WiFiScanNetwork*>& networks); void appendVisibleScanResultsJson(String& json, const std::vector<const WiFiManager::WiFiScanNetwork*>& networks);
void applyWifiAndParamsFromRequest(AsyncWebServerRequest *request); void applyWifiAndParamsFromRequest(AsyncWebServerRequest *request);
bool buildStationProfilesFromRequest(AsyncWebServerRequest *request, WiFiManagerStationProfiles& profiles);
void buildPlainStatusSummary(String& out); void buildPlainStatusSummary(String& out);
void appendPortalJsonStaticFields(String& json, bool& first); void appendPortalJsonStaticFields(String& json, bool& first);
void appendPortalJsonCustomParams(String& json, bool& first); void appendPortalJsonCustomParams(String& json, bool& first);
String composePortalStylesheet() const;
void appendPortalExtraInfoSectionsJson(String& json, bool& first); void appendPortalExtraInfoSectionsJson(String& json, bool& first);
void appendPortalExtraHomeCardsJson(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-border:#e2e8f0;"
"--wm-brand:#0ea5e9;" "--wm-brand:#0ea5e9;"
"--wm-brand-hover:#0284c7;" "--wm-brand-hover:#0284c7;"
"--wm-brand-text:#ffffff;"
"--wm-danger:#dc2626;" "--wm-danger:#dc2626;"
"--wm-danger-hover:#b91c1c;" "--wm-danger-hover:#b91c1c;"
"--wm-success:#16a34a;" "--wm-success:#16a34a;"
@@ -128,16 +129,16 @@ const char CSS_STYLE[] PROGMEM = "<style>"
"a.wm-btn{box-sizing:border-box;text-decoration:none}" "a.wm-btn{box-sizing:border-box;text-decoration:none}"
".wm-btn .wm-icon-label{justify-content:center}" ".wm-btn .wm-icon-label{justify-content:center}"
".wm-btn--block{width:100%}" ".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--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{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--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)}" ".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);" "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}" "background:var(--wm-bg);font-size:.9rem}"
/* Legacy: unclassed submit in forms */ /* 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)}" "button[type=submit]:hover{background:var(--wm-brand-hover)}"
/* Device actions / button stack */ /* Device actions / button stack */
".wm-actions-stack{display:flex;flex-direction:column;gap:10px;margin-top:4px}" ".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-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{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap}"
".wm-dialog-actions .wm-btn{min-width:108px}" ".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}" ".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.ok{background:rgba(22,163,74,.2)}"
"body.invert .wm-toast.err{background:rgba(220,38,38,.2)}" "body.invert .wm-toast.err{background:rgba(220,38,38,.2)}"
+83 -17
View File
@@ -66,8 +66,13 @@ function bindPortalChromeEvents(){
} }
function bindFormSubmitHandlers(){ function bindFormSubmitHandlers(){
var wf=$('wm-wifi-form'), pf=$('wm-param-form'), of=$('wm-ota-form'); 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(wf && window.portalWifiSave) wf.addEventListener('submit', window.portalWifiSave); 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(pf && window.portalParamSave) pf.addEventListener('submit', window.portalParamSave);
if(of && window.portalOtaSubmit) of.addEventListener('submit', window.portalOtaSubmit); if(of && window.portalOtaSubmit) of.addEventListener('submit', window.portalOtaSubmit);
} }
@@ -315,7 +320,8 @@ function viewHome(){
var timeoutSeconds=ctx.portalTimeoutSecondsRemaining||0; var timeoutSeconds=ctx.portalTimeoutSecondsRemaining||0;
var html=navBar('home'); var html=navBar('home');
html+="<header class='wm-hero'>"; 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>"; html+="<h1 class='wm-hero-title'>"+t+"</h1>";
if(ident){html+="<p class='wm-hero-sub'>"+ident+"</p>";} if(ident){html+="<p class='wm-hero-sub'>"+ident+"</p>";}
if(brand.homeIntro){html+="<p class='wm-hero-intro'>"+esc(brand.homeIntro)+"</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'; 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'); var scanResults=$('wm-scan-results');
if(scanResults){ if(scanResults){
@@ -473,8 +487,8 @@ function bindWifiViewEvents(){
while(el && el !== scanResults){ while(el && el !== scanResults){
if(el.tagName==='A' && el.getAttribute('data-ssid')){ if(el.tagName==='A' && el.getAttribute('data-ssid')){
ev.preventDefault(); ev.preventDefault();
var inp=$('wm-s'); var inp=$('wm-s0')||$('wm-s');
var pass=$('wm-p'); var pass=$('wm-p0')||$('wm-p');
if(inp)inp.value=el.getAttribute('data-ssid')||''; if(inp)inp.value=el.getAttribute('data-ssid')||'';
if(pass){ if(pass){
if(pass.scrollIntoView)pass.scrollIntoView({behavior:'smooth',block:'center'}); 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(){ function viewWifi(){
setView(navBar('wifi')+"<div class='wm-card'><p class='wm-lead'>Loading WiFi options…</p></div>"); setView(navBar('wifi')+"<div class='wm-card'><p class='wm-lead'>Loading WiFi options…</p></div>");
api('/api/wifi/meta').then(function(res){ api('/api/wifi/meta').then(function(res){
@@ -501,18 +539,28 @@ function viewWifi(){
html+="<div id='wm-scan-results'></div>"; 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+="<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+="<div class='wm-card'>"+cardTitleHtml('wifi','Network details');
html+="<form id='wm-wifi-form'>"; if(Array.isArray(m.profiles) && m.profiles.length){
html+=renderFieldList(m.wifiFields||[]); html+=renderStationProfileFields(m.profiles);
var wf=m.wifiFields||[]; html+="<form class='wm-wifi-data-form' id='wm-wifi-form'>";
var hasPass=false; }else{
var i; html+="<form class='wm-wifi-data-form' id='wm-wifi-form'>";
for(i=0;i<wf.length;i++){if(wf[i]&&wf[i].id==='p'){hasPass=true;break;}} html+=renderFieldList(m.wifiFields||[]);
if(hasPass){ var wf=m.wifiFields||[];
html+="<div class='wm-checkbox-row'><input type='checkbox' id='wm-showpass'/> <label for='wm-showpass'>Show password</label></div>"; 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.staticFields||[]);
html+=renderFieldList(m.params||[]); 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>"; html+="<div id='wm-wifi-msg'></div></div>";
setView(html); setView(html);
bindWifiViewEvents(); bindWifiViewEvents();
@@ -595,9 +643,17 @@ function pollWifiConnectStatus(){
poll(); poll();
} }
window.portalWifiSave=function(ev){ function collectWifiFormData(){
ev.preventDefault(); var fd=new FormData(), forms=document.querySelectorAll('.wm-wifi-data-form'), i;
var fd=new FormData(document.getElementById('wm-wifi-form')); 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'); var msg=$('wm-wifi-msg');
if(msg)msg.innerHTML='Submitting...'; if(msg)msg.innerHTML='Submitting...';
api('/api/wifi/save',{method:'POST',body:fd}).then(function(res){ api('/api/wifi/save',{method:'POST',body:fd}).then(function(res){
@@ -612,6 +668,16 @@ window.portalWifiSave=function(ev){
} }
}); });
return false; 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 --- // --- Info view ---
@@ -7,7 +7,7 @@
* Single-page portal shell: only HTML document served for GET /. * Single-page portal shell: only HTML document served for GET /.
* Dynamic UI is driven by embedded JS + JSON APIs under /api/... * 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. * 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'/>" "<meta name='viewport' content='width=device-width,initial-scale=1,user-scalable=no'/>"
"<title>%PAGE_TITLE%</title>" "<title>%PAGE_TITLE%</title>"
"%STYLES%" "%STYLES%"
"%PORTAL_THEME%"
"</head>" "</head>"
"<body class='portal'>" "<body class='portal'>"
"<div id='wm-toast' class='wm-toast' aria-live='polite' role='status' style='display:none'></div>" "<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>" "<script>"
"%PORTAL_APP_JS%" "%PORTAL_APP_JS%"
"</script>" "</script>"
"<script>"
"%PORTAL_APPEND_JS%"
"</script>"
"</body>" "</body>"
"</html>"; "</html>";
+553 -31
View File
@@ -27,6 +27,7 @@ uint8_t WiFiManager::_lastconxresulttmp = WL_IDLE_STATUS;
namespace { namespace {
constexpr size_t kMaxHostnameLength = 32; constexpr size_t kMaxHostnameLength = 32;
constexpr uint8_t kSoftApStartMaxAttempts = 3; constexpr uint8_t kSoftApStartMaxAttempts = 3;
constexpr unsigned long kStationProfileSwitchDelayMs = 2000UL; // ESP WiFi needs time to leave a failed association.
bool isValidHostnameChar(char c) { bool isValidHostnameChar(char c) {
return isAlphaNumeric(c) || c == '-'; return isAlphaNumeric(c) || c == '-';
@@ -55,6 +56,25 @@ bool normalizeHostname(String& hostname) {
return true; 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 } // namespace
/** /**
@@ -148,6 +168,7 @@ WiFiManager::WiFiManager() {
} }
void WiFiManager::WiFiManagerInit(){ void WiFiManager::WiFiManagerInit(){
_portalBrand.title = WiFiManagerPortalText::progmem(kDefaultPortalTitle);
if (_logEnabled && (uint8_t)_runtimeMaxLevel >= (uint8_t)WiFiManagerLogLevel::Trace) { if (_logEnabled && (uint8_t)_runtimeMaxLevel >= (uint8_t)WiFiManagerLogLevel::Trace) {
debugPlatformInfo(); debugPlatformInfo();
} }
@@ -197,6 +218,465 @@ void WiFiManager::_end(){
if(_userpersistent) WiFi.persistent(true); // reenable persistent, there is no getter we rely on _userpersistent 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() { boolean WiFiManager::autoConnect() {
String ssid = getDefaultAPName(); String ssid = getDefaultAPName();
return autoConnect(ssid.c_str(), NULL); return autoConnect(ssid.c_str(), NULL);
@@ -388,16 +868,6 @@ bool WiFiManager::startAP(){
log(WiFiManagerLogLevel::Info, kWiFiMgrLogSubsystem, F("StartAP with SSID: "),_apName); log(WiFiManagerLogLevel::Info, kWiFiMgrLogSubsystem, F("StartAP with SSID: "),_apName);
#endif #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 // setup optional soft AP static ip config
if (_ap_static_ip) { if (_ap_static_ip) {
#ifndef WM_NO_LOG #ifndef WM_NO_LOG
@@ -656,7 +1126,10 @@ boolean WiFiManager::process(){
#endif #endif
processScan(); processScan();
processPortalConnect(); processStationController();
if (!_stationCandidateFromPortal) {
processPortalConnect();
}
if(webPortalActive || configPortalActive){ if(webPortalActive || configPortalActive){
// if timed out or abort, break // if timed out or abort, break
@@ -813,7 +1286,8 @@ void WiFiManager::processPortalConnect() {
} }
return; 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)); failPortalConnect(status, getWLStatusString(status));
return; return;
} }
@@ -2095,20 +2569,78 @@ void WiFiManager::setWiFiAPHidden(bool hidden){
} }
void WiFiManager::portalSetBrandTitle(const String& title) { bool WiFiManager::canChangePortalPresentation() const {
_portalBrand.title = title; return !configPortalActive && !webPortalActive;
} }
void WiFiManager::portalSetContextIdentityText(const String& identityText) { bool WiFiManager::isPortalThemeValid(const WiFiManagerPortalTheme& theme) const {
_portalBrand.identityTextOverride = identityText; 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) { void WiFiManager::rebuildPortalThemeStyle() {
_portalBrand.homeIntro = text; _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) { bool WiFiManager::setPortalConfig(const WiFiManagerPortalConfig& config) {
_portalBrand.logoSvg = svgMarkup; 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) { void WiFiManager::portalSetPageInfoVisible(bool visible) {
@@ -2123,6 +2655,7 @@ void WiFiManager::portalSetPageSetupVisible(bool visible) {
_portalPages.setupVisible = visible; _portalPages.setupVisible = visible;
} }
void WiFiManager::portalSetActionEraseVisible(bool visible) { void WiFiManager::portalSetActionEraseVisible(bool visible) {
_portalActions.eraseVisible = visible; _portalActions.eraseVisible = visible;
} }
@@ -2227,17 +2760,6 @@ void WiFiManager::portalClearHomeCards() {
_portalStructured.homeCards.clear(); _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 * 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 { namespace {
const char kEmptyPortalPlaceholder[] PROGMEM = "";
inline void reservePage(String& page, size_t extraBytes = WM_PAGE_RESERVE_BYTES) { inline void reservePage(String& page, size_t extraBytes = WM_PAGE_RESERVE_BYTES) {
if (extraBytes == 0) return; if (extraBytes == 0) return;
const size_t targetLen = page.length() + extraBytes; const size_t targetLen = page.length() + extraBytes;
@@ -60,24 +77,26 @@ void configureDynamicStringDescriptor(DynamicDataDescriptor& descriptor, String&
descriptor.userData = &value; descriptor.userData = &value;
} }
String readProgmemString(const char* p) { const char* portalTextGetter(void* userData) {
return String(FPSTR(p)); const auto* text = static_cast<const WiFiManagerPortalText*>(userData);
return text && text->storage == WiFiManagerPortalStorage::Ram && text->data
? text->data
: "";
} }
String stripStyleWrapper(const String& styleBlock) { size_t portalTextLengthGetter(const char* /*data*/, void* userData) {
String css = styleBlock; const auto* text = static_cast<const WiFiManagerPortalText*>(userData);
const String openTag = F("<style>"); return text ? text->length() : 0;
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;
} }
void configurePortalTextDescriptor(DynamicDataDescriptor& descriptor,
const WiFiManagerPortalText& text) {
descriptor.getter = &portalTextGetter;
descriptor.getLength = &portalTextLengthGetter;
descriptor.userData = const_cast<WiFiManagerPortalText*>(&text);
}
const char* portalHomeCardKindJson(PortalHomeCardKind k) { const char* portalHomeCardKindJson(PortalHomeCardKind k) {
switch (k) { switch (k) {
case PortalHomeCardKind::Text: 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) // %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 // %BOOTSTRAP_JSON% -> initial SPA runtime payload
// %PORTAL_APP_JS% -> embedded SPA source // %PORTAL_APP_JS% -> embedded SPA source
// %PORTAL_APPEND_JS% -> consumer hook script (portalAppendJs)
struct PortalShellRenderBundle { struct PortalShellRenderBundle {
String bootstrapJson; String bootstrapJson;
String pageTitleStatic; WiFiManagerPortalText pageTitle;
String stylesStatic;
String appendJsStatic;
PlaceholderRegistry registry; PlaceholderRegistry registry;
TemplateContext context; TemplateContext context;
DynamicDataDescriptor bootstrapDescriptor; DynamicDataDescriptor bootstrapDescriptor;
DynamicDataDescriptor pageTitleDescriptor; DynamicDataDescriptor pageTitleDescriptor;
DynamicDataDescriptor stylesDescriptor; DynamicDataDescriptor themeDescriptor;
DynamicDataDescriptor appendJsDescriptor;
PortalShellRenderBundle() PortalShellRenderBundle()
: registry(WM_TEMPLATE_REGISTRY_CAPACITY), : registry(WM_TEMPLATE_REGISTRY_CAPACITY),
bootstrapDescriptor{}, bootstrapDescriptor{},
pageTitleDescriptor{}, pageTitleDescriptor{},
stylesDescriptor{}, themeDescriptor{} {}
appendJsDescriptor{} {}
}; };
// Custom HTML attribute strings (e.g. from DeviceFramework generateCustomHTML) may include // 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) {} 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) { void WiFiManagerHandlers::appendPortalExtraInfoSectionsJson(String& json, bool& first) {
if (_wm == nullptr) { if (_wm == nullptr) {
return; return;
@@ -199,7 +196,6 @@ void WiFiManagerHandlers::appendPortalExtraInfoSectionsJson(String& json, bool&
json += F("]}"); json += F("]}");
} }
} }
void WiFiManagerHandlers::appendPortalExtraHomeCardsJson(String& json, bool& first) { void WiFiManagerHandlers::appendPortalExtraHomeCardsJson(String& json, bool& first) {
if (_wm == nullptr) { if (_wm == nullptr) {
return; return;
@@ -686,25 +682,24 @@ void WiFiManagerHandlers::handleRoot(AsyncWebServerRequest *request) {
// - WiFiManagerHandlers owns request-time shell assembly; WiFiManagerServer owns HTTP lifecycle only. // - WiFiManagerHandlers owns request-time shell assembly; WiFiManagerServer owns HTTP lifecycle only.
// - Build one request-scoped PortalShellRenderBundle. // - Build one request-scoped PortalShellRenderBundle.
// - Populate one request-scoped PlaceholderRegistry with shell defaults + request payloads. // - 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>(); auto bundle = std::make_shared<PortalShellRenderBundle>();
const bool hasCustomStyles = _wm != nullptr && bundle->pageTitle = _wm ? _wm->_portalBrand.title : WiFiManagerPortalText{};
(_wm->_portalAssets.overriddenCss.length() > 0 || _wm->_portalAssets.appendedCss.length() > 0);
bundle->pageTitleStatic = _wm ? _wm->_portalBrand.title : String();
bundle->bootstrapJson = buildPortalBootstrapJson(); 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); bundle->registry.registerProgmemData("%PORTAL_APP_JS%", PORTAL_APP_JS);
if (hasCustomStyles) { if (_wm) {
bundle->stylesStatic = composePortalStylesheet(); configureDynamicStringDescriptor(bundle->themeDescriptor, _wm->_portalThemeStyle);
configureDynamicStringDescriptor(bundle->stylesDescriptor, bundle->stylesStatic); bundle->registry.registerDynamicData("%PORTAL_THEME%", &bundle->themeDescriptor);
bundle->registry.registerDynamicData("%STYLES%", &bundle->stylesDescriptor);
} else { } 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); configureDynamicStringDescriptor(bundle->bootstrapDescriptor, bundle->bootstrapJson);
bundle->registry.registerDynamicData("%BOOTSTRAP_JSON%", &bundle->bootstrapDescriptor); 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){ void WiFiManagerHandlers::doParamSave(WiFiManager::WiFiManagerRequestArgs requestArgs){
if ( _wm->_presaveparamscallback != NULL) { if ( _wm->_presaveparamscallback != NULL) {
_wm->_presaveparamscallback(); _wm->_presaveparamscallback();
@@ -1020,7 +1071,9 @@ String WiFiManagerHandlers::buildPortalBootstrapJson() {
json += F("\",\"homeIntro\":\""); json += F("\",\"homeIntro\":\"");
jsonAppendEscaped(json, _wm->_portalBrand.homeIntro); jsonAppendEscaped(json, _wm->_portalBrand.homeIntro);
json += F("\",\"logoSvg\":\""); 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("\"}");
json += F(",\"context\":{"); json += F(",\"context\":{");
json += F("\"portalActive\":"); json += F("\"portalActive\":");
@@ -1038,15 +1091,12 @@ String WiFiManagerHandlers::buildPortalBootstrapJson() {
json += String(timeoutRemainingSeconds); json += String(timeoutRemainingSeconds);
} }
json += F(",\"identityText\":\""); json += F(",\"identityText\":\"");
{ if (!_wm->_portalBrand.identityTextOverride.empty()) {
String idText; jsonAppendEscaped(json, _wm->_portalBrand.identityTextOverride);
if (_wm->_portalBrand.identityTextOverride.length() > 0) { } else {
idText = _wm->_portalBrand.identityTextOverride; const String idText = _wm->configPortalActive
} else if (_wm->configPortalActive) { ? _wm->_apName
idText = _wm->_apName; : _wm->getWiFiHostname() + " - " + WiFi.localIP().toString();
} else {
idText = _wm->getWiFiHostname() + " - " + WiFi.localIP().toString();
}
jsonAppendEscaped(json, idText); jsonAppendEscaped(json, idText);
} }
json += F("\",\"statusSummary\":\""); json += F("\",\"statusSummary\":\"");
@@ -1193,6 +1243,48 @@ void WiFiManagerHandlers::handleApiWifiScan(AsyncWebServerRequest *request) {
} }
String WiFiManagerHandlers::buildApiWifiMetaJson() { 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 ssidPlaceholder = _wm->WiFi_SSID();
String passwordPlaceholder = ""; String passwordPlaceholder = "";
switch (_wm->_portalPasswordPlaceholderMode) { switch (_wm->_portalPasswordPlaceholderMode) {
@@ -1240,6 +1332,30 @@ void WiFiManagerHandlers::handleApiWifiSave(AsyncWebServerRequest *request) {
#endif #endif
handleRequest(request); handleRequest(request);
applyWifiAndParamsFromRequest(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); _wm->queuePortalConnect(_wm->_ssid, _wm->_pass);
sendApiJson( sendApiJson(
request, 202, request, 202,
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "WiFiManager", "name": "WiFiManager",
"version": "3.0.6", "version": "3.1.0",
"keywords": [ "keywords": [
"wifi", "wifi",
"wi-fi", "wi-fi",
@@ -47,18 +47,18 @@
{ {
"owner": "esp32async", "owner": "esp32async",
"name": "ESPAsyncTCP", "name": "ESPAsyncTCP",
"version": "2.0.0", "version": "^2.0.0",
"platforms": "espressif8266" "platforms": "espressif8266"
}, },
{ {
"owner": "esp32async", "owner": "esp32async",
"name": "AsyncTCP", "name": "AsyncTCP",
"version": "3.4.9", "version": "^3.4.9",
"platforms": "espressif32" "platforms": "espressif32"
}, },
{ {
"name": "DeviceFrameworkTemplateEngine", "name": "DeviceFrameworkTemplateEngine",
"version": "https://github.com/alexhopeoconnor/DFTE.git#v1.0.2" "version": "https://github.com/alexhopeoconnor/DFTE.git#v1.1.0"
} }
], ],
"build": { "build": {
@@ -71,8 +71,8 @@
"lib/WiFiManager/src", "lib/WiFiManager/src",
"LICENSE", "LICENSE",
"README.md", "README.md",
"library.json", "CHANGELOG.md",
"tools" "library.json"
] ]
}, },
"$schema": "https://raw.githubusercontent.com/platformio/platformio-core/develop/platformio/assets/schema/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 -DUNIT_TEST
lib_deps = lib_deps =
ESP32Async/ESPAsyncWebServer@3.9.1 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 ESP32Async/ESPAsyncTCP@2.0.0
[env:esp32] [env:esp32]
platform = espressif32@6.13.0 platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
board = esp32dev board = esp32dev
framework = arduino framework = arduino
monitor_speed = 115200 monitor_speed = 115200
@@ -30,12 +30,14 @@ build_unflags =
-std=gnu++11 -std=gnu++11
build_flags = build_flags =
-std=gnu++14 -std=gnu++14
-DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
-DWM_LOG_LEVEL=5 -DWM_LOG_LEVEL=5
-DUNIT_TEST -DUNIT_TEST
lib_deps = lib_deps =
ESP32Async/ESPAsyncWebServer@3.9.1 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/AsyncTCP@3.4.9 ESP32Async/AsyncTCP@^3.4.9
; Optional: compile tests with DFTE logs bridged into WiFiManager::log (see README) ; Optional: compile tests with DFTE logs bridged into WiFiManager::log (see README)
[env:esp8266_dfte_log] [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 exit 1
fi fi
grep -q "^## $version$" "$root/CHANGELOG.md" || {
echo "CHANGELOG.md has no $version heading" >&2
exit 1
}
if [[ -f "$root/library.properties" ]]; then if [[ -f "$root/library.properties" ]]; then
properties_version="$(sed -n 's/^version=//p' "$root/library.properties" | head -n 1)" properties_version="$(sed -n 's/^version=//p' "$root/library.properties" | head -n 1)"
if [[ "$properties_version" != "$version" ]]; then if [[ "$properties_version" != "$version" ]]; then
@@ -27,6 +32,7 @@ if [[ -f "$root/library.properties" ]]; then
fi fi
fi fi
git -C "$root" diff --check
package_dir="$(mktemp -d)" package_dir="$(mktemp -d)"
trap 'rm -rf "$package_dir"' EXIT trap 'rm -rf "$package_dir"' EXIT
pio pkg pack "$root" --output "$package_dir/package.tar.gz" >/dev/null 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"
+3 -1
View File
@@ -20,11 +20,13 @@ lib_deps =
[env:esp32] [env:esp32]
extends = common extends = common
platform = espressif32@6.13.0 platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
board = esp32dev board = esp32dev
build_unflags = -std=gnu++11 build_unflags = -std=gnu++11
build_flags = build_flags =
-std=gnu++14 -std=gnu++14
-DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
-DWM_LOG_LEVEL=3 -DWM_LOG_LEVEL=3
lib_deps = lib_deps =
${common.lib_deps} ${common.lib_deps}
+36
View File
@@ -1,9 +1,45 @@
#include <Arduino.h> #include <Arduino.h>
#include <WiFiManager.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; WiFiManager wifiManager;
void setup() { void setup() {
wifiManager.setPortalConfig(kPortalConfig);
wifiManager.setConfigPortalTimeout(1); wifiManager.setConfigPortalTimeout(1);
} }
+3 -1
View File
@@ -36,9 +36,11 @@ TestCase tests[] = {
TEST_ENTRY(test_config_portal_already_active), TEST_ENTRY(test_config_portal_already_active),
TEST_ENTRY(test_get_config_portal_ssid), TEST_ENTRY(test_get_config_portal_ssid),
TEST_ENTRY(test_bootstrap_json_portal_feature_flags), 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_contract_v2),
TEST_ENTRY(test_bootstrap_json_snapshot_consistency), TEST_ENTRY(test_bootstrap_json_snapshot_consistency),
TEST_ENTRY(test_root_render_interleaved_context_isolation), TEST_ENTRY(test_root_render_interleaved_context_isolation),
TEST_ENTRY(test_portal_presentation_configuration),
// Non-blocking tests // Non-blocking tests
TEST_ENTRY(test_nonblocking_process), TEST_ENTRY(test_nonblocking_process),
@@ -104,7 +106,7 @@ TestCase tests[] = {
// Template rendering tests // Template rendering tests
TEST_ENTRY(test_shell_template_renders_core_placeholders), 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 // State transition tests
TEST_ENTRY(test_portal_to_connected_transition), 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_config_portal_already_active();
void test_get_config_portal_ssid(); void test_get_config_portal_ssid();
void test_bootstrap_json_portal_feature_flags(); void test_bootstrap_json_portal_feature_flags();
void test_portal_default_presentation();
void test_bootstrap_json_contract_v2(); void test_bootstrap_json_contract_v2();
void test_bootstrap_json_snapshot_consistency(); void test_bootstrap_json_snapshot_consistency();
void test_root_render_interleaved_context_isolation(); void test_root_render_interleaved_context_isolation();
void test_portal_presentation_configuration();
// Non-blocking tests // Non-blocking tests
void test_nonblocking_process(); void test_nonblocking_process();
@@ -108,7 +110,7 @@ void test_scan_generation_invalidated_on_reset();
// Template rendering tests // Template rendering tests
void test_shell_template_renders_core_placeholders(); 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 // API JSON + captive helper tests
void test_captive_redirect_host_rule(); void test_captive_redirect_host_rule();
@@ -88,8 +88,10 @@ void test_ui_customization() {
WiFiManager wm; WiFiManager wm;
// Minimal customization retained: title // Minimal customization uses the complete static portal configuration.
wm.portalSetBrandTitle("MyDevice"); WiFiManagerPortalConfig portal;
portal.title = WiFiManagerPortalText::ram("MyDevice");
TEST_ASSERT_TRUE(wm.setPortalConfig(portal));
// All setters executed without crash // All setters executed without crash
TEST_ASSERT_TRUE_MESSAGE(true, "UI customization options 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(true);
wm.portalSetBehaviorCaptivePortalEnabled(false); wm.portalSetBehaviorCaptivePortalEnabled(false);
// Test custom title path // Test the typed portal presentation path.
wm.portalSetBrandTitle("TestTitle"); WiFiManagerPortalConfig portal;
portal.title = WiFiManagerPortalText::ram("TestTitle");
TEST_ASSERT_TRUE(wm.setPortalConfig(portal));
// Call setters multiple times with different values. // Call setters multiple times with different values.
wm.setConfigPortalTimeout(60); wm.setConfigPortalTimeout(60);
@@ -76,16 +76,29 @@ void test_bootstrap_json_portal_feature_flags() {
Serial.println("[TEST] Bootstrap portal feature flags test completed successfully"); 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() { void test_bootstrap_json_contract_v2() {
Serial.println("[TEST] Testing bootstrap JSON v2 contract..."); Serial.println("[TEST] Testing bootstrap JSON v2 contract...");
WiFiManager wm; WiFiManager wm;
WiFiManagerHandlers handlers(&wm); WiFiManagerHandlers handlers(&wm);
wm.portalSetBrandTitle("Solar Battery Monitor Setup"); WiFiManagerPortalConfig portal;
wm.portalSetContextIdentityText("Solar Battery Monitor"); portal.title = WiFiManagerPortalText::ram("Solar Battery Monitor Setup");
wm.portalSetBrandHomeIntro("Connect this device to WiFi and finish setup."); portal.identityText = WiFiManagerPortalText::ram("Solar Battery Monitor");
wm.portalSetBrandLogoSvg("<svg viewBox='0 0 24 24'></svg>"); 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.portalSetPageInfoVisible(true);
wm.portalSetPageUpdateVisible(false); wm.portalSetPageUpdateVisible(false);
wm.portalSetPageSetupVisible(true); wm.portalSetPageSetupVisible(true);
@@ -163,11 +176,11 @@ void test_root_render_interleaved_context_isolation() {
registryA.registerProgmemData("%STYLES%", kEmptyTemplateChunk); registryA.registerProgmemData("%STYLES%", kEmptyTemplateChunk);
registryA.registerProgmemData("%PAGE_TITLE%", kTestTitle); registryA.registerProgmemData("%PAGE_TITLE%", kTestTitle);
registryA.registerProgmemData("%PORTAL_APPEND_JS%", kEmptyTemplateChunk); registryA.registerProgmemData("%PORTAL_THEME%", kEmptyTemplateChunk);
registryB.registerProgmemData("%STYLES%", kEmptyTemplateChunk); registryB.registerProgmemData("%STYLES%", kEmptyTemplateChunk);
registryB.registerProgmemData("%PAGE_TITLE%", kTestTitle); registryB.registerProgmemData("%PAGE_TITLE%", kTestTitle);
registryB.registerProgmemData("%PORTAL_APPEND_JS%", kEmptyTemplateChunk); registryB.registerProgmemData("%PORTAL_THEME%", kEmptyTemplateChunk);
String bootstrapA = F("{\"ctx\":\"A\"}"); String bootstrapA = F("{\"ctx\":\"A\"}");
String appJsA = F("// shell 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"); 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 kDocTitle[] PROGMEM = "Config ESP";
const char kBootstrapJson[] PROGMEM = "{\"title\":\"Test\"}"; const char kBootstrapJson[] PROGMEM = "{\"title\":\"Test\"}";
const char kPortalAppJs[] PROGMEM = "console.log('portal');"; const char kPortalAppJs[] PROGMEM = "console.log('portal');";
const char kPortalAppendJs[] PROGMEM = ""; const char kPortalTheme[] PROGMEM = "";
const char* dynamicStringGetter(void* userData) { const char* dynamicStringGetter(void* userData) {
const auto* value = static_cast<const String*>(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("%PAGE_TITLE%", kDocTitle);
registry.registerProgmemData("%BOOTSTRAP_JSON%", kBootstrapJson); registry.registerProgmemData("%BOOTSTRAP_JSON%", kBootstrapJson);
registry.registerProgmemData("%PORTAL_APP_JS%", kPortalAppJs); 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); 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"); Serial.println("[TEST] Portal shell template rendering test completed successfully");
} }
void test_shell_template_renders_dynamic_styles_with_percent_values() { void test_shell_template_renders_dynamic_theme_with_percent_values() {
Serial.println("[TEST] Testing portal shell dynamic styles placeholder with percent values..."); Serial.println("[TEST] Testing portal shell dynamic theme placeholder with percent values...");
PlaceholderRegistry registry(8); 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 title = F("Config ESP");
String bootstrap = F("{\"title\":\"Test\"}"); String bootstrap = F("{\"title\":\"Test\"}");
String appendJs = F(""); DynamicDataDescriptor themeDescriptor{};
DynamicDataDescriptor stylesDescriptor{};
DynamicDataDescriptor titleDescriptor{}; DynamicDataDescriptor titleDescriptor{};
DynamicDataDescriptor bootstrapDescriptor{}; DynamicDataDescriptor bootstrapDescriptor{};
DynamicDataDescriptor appendJsDescriptor{};
configureDescriptor(stylesDescriptor, styles); configureDescriptor(themeDescriptor, theme);
configureDescriptor(titleDescriptor, title); configureDescriptor(titleDescriptor, title);
configureDescriptor(bootstrapDescriptor, bootstrap); 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("%PAGE_TITLE%", &titleDescriptor));
TEST_ASSERT_TRUE(registry.registerDynamicData("%BOOTSTRAP_JSON%", &bootstrapDescriptor)); TEST_ASSERT_TRUE(registry.registerDynamicData("%BOOTSTRAP_JSON%", &bootstrapDescriptor));
TEST_ASSERT_TRUE(registry.registerProgmemData("%PORTAL_APP_JS%", kPortalAppJs)); 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); 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>Config ESP</title>"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("{\"title\":\"Test\"}")); 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");
} }