Compare commits

...
14 Commits
52 changed files with 2725 additions and 552 deletions
+17 -1
View File
@@ -2,12 +2,27 @@ name: Build
on:
push:
branches:
- device-framework
tags:
- "v*"
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
documentation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bash -n scripts/*.sh
- run: ./scripts/check-docs.sh
compile-tests:
runs-on: ubuntu-latest
strategy:
@@ -20,4 +35,5 @@ jobs:
with:
python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19
- run: pio test -e ${{ matrix.environment }} --without-uploading --without-testing
- run: ./scripts/test.sh compile --platform ${{ matrix.environment }}
- run: ./scripts/test.sh examples --platform ${{ matrix.environment }}
+10 -1
View File
@@ -17,7 +17,16 @@ jobs:
with:
python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19
- run: ./scripts/test.sh compile --platform esp8266
- run: ./scripts/test.sh examples --platform esp8266
- run: ./scripts/test.sh compile --platform esp32
- run: ./scripts/test.sh examples --platform esp32
- run: ./scripts/check-docs.sh
- run: ./scripts/prepare-release.sh "$GITHUB_REF_NAME"
- run: gh release create "$GITHUB_REF_NAME" --generate-notes --title "$GITHUB_REF_NAME"
- run: ./scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md"
- run: >-
gh release create "$GITHUB_REF_NAME"
--title "WiFiManager $GITHUB_REF_NAME"
--notes-file "$RUNNER_TEMP/release-notes.md"
env:
GH_TOKEN: ${{ github.token }}
+39
View File
@@ -1,5 +1,44 @@
# Changelog
## 3.2.1
- Ensure externally supplied station profiles remain authoritative on ESP32 by clearing stale SDK-owned credentials before opening the portal for an empty or invalid external profile set.
- Pin DFTE 1.2.0, the tested template-engine release used by the maintained portal.
## 3.2.0
- Refine the device-hosted portal with a startup Wi-Fi scan, stable loading overlays, clearer connection progress, and a resettable configuration timeout.
- Keep a successful portal-to-station hand-off reachable until the browser acknowledges its redirect, with a bounded fallback for captive or headless clients.
- Rename the presentation field `homeIntro` to `tagline` so portal identity and wording are clearer; update portal bootstrap contract to v3.
- Add focused Basic Portal, Branded Portal, Custom Portal Content, and Station Profiles examples for ESP8266 and ESP32, with real-hardware README captures.
- Strengthen release CI with documentation and clean-consumer/example compilation checks for both supported targets.
## 3.1.0
- Add an opt-in primary/fallback station-profile controller with bounded failover, reconnection, a durable consumer-supplied store, and profile-aware portal APIs. DeviceFramework uses this to persist verified WiFi profiles transactionally.
- Add portable portal branding and presentation hooks, including theme-aware shell and template rendering, without requiring DeviceFramework.
- Pin ESP32 tests to the Arduino 3-compatible pioarduino platform release and resolve DFTE 1.1.0.
## 3.0.6
- Correct async PlatformIO dependency owners to the registry's canonical lowercase identity, so a clean consumer builds WiFiManager and its ESP8266/ESP32 transport dependencies without duplicating them in `lib_deps`. Remove the superseded include-path bridge.
## 3.0.5
- Add a library-owned PlatformIO bridge for the asynchronous web/TCP include paths required when WiFiManager is nested beneath DeviceFramework.
## 3.0.4
- Set the package's deep PlatformIO dependency-discovery mode so WiFiManager's asynchronous web/TCP children remain available when it is consumed through DeviceFramework.
## 3.0.3
- Declare the ESP8266 and ESP32 async TCP libraries explicitly in the PlatformIO package manifest so clean consumers resolve the headers required by the asynchronous portal.
## 3.0.2
- Update the pinned DFTE dependency to the iterator-lifecycle and configuration-safe 1.0.2 release.
## 3.0.1
- Redact WiFi, AP, portal-form, and custom-parameter values from diagnostic logs.
+65 -177
View File
@@ -1,203 +1,91 @@
# WiFiManager
This repository is a **breaking fork** of upstream [`tzapu/WiFiManager`](https://github.com/tzapu/WiFiManager).
It is not a drop-in replacement for upstream behavior, APIs, templates, or portal customization patterns.
WiFiManager gives ESP8266 and ESP32 firmware a polished, self-hosted Wi-Fi setup experience. It reconnects to saved networks and opens a temporary captive portal when it cannot, so users can configure the device from any phone or browser without a cloud service or companion app.
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.
## See it on real hardware
**This repository:** [alexhopeoconnor/WiFiManager](https://github.com/alexhopeoconnor/WiFiManager)
**Upstream:** [tzapu/WiFiManager](https://github.com/tzapu/WiFiManager)
| Brand the built-in portal | Combine Wi-Fi and application setup |
| --- | --- |
| ![Branded WiFiManager portal overview on an ESP32.](docs/assets/portal-esp32-branded-overview.png) | ![WiFiManager network picker and custom MQTT broker field on an ESP8266.](docs/assets/portal-esp8266-custom-wifi.png) |
| Give each product its own title, identity, icon, and color theme without copying portal HTML. | Show live nearby networks and collect application values, such as an MQTT broker, in the same setup flow. |
## 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
## Start with a working portal
```cpp
WiFiManager wm;
#include <Arduino.h>
#include <WiFiManager.h>
wm.portalSetBrandTitle("Solar Battery Monitor Setup");
wm.portalSetContextIdentityText("Solar Battery Monitor");
wm.portalSetBrandHomeIntro(
"Connect your monitor to WiFi, then review battery and inverter settings."
);
wm.portalSetBrandLogoSvg(
"<svg viewBox='0 0 24 24' aria-hidden='true'>"
"<path d='M12 2L4 12h5l-1 10 8-10h-5z'></path>"
"</svg>"
);
WiFiManager wifi;
wm.portalSetPageInfoVisible(true);
wm.portalSetPageUpdateVisible(false);
wm.portalSetActionEraseVisible(false);
wm.portalSetActionRestartVisible(true);
wm.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage);
void setup() {
Serial.begin(115200);
wifi.setConfigPortalTimeout(180);
wifi.autoConnect("Device Setup", "change-me");
}
wm.portalSetBehaviorCaptivePortalEnabled(true);
wm.portalSetBehaviorConnectOnSave(true);
wm.portalSetBehaviorExitAllowed(true);
wm.portalSetFieldPasswordPlaceholderMode(PortalPasswordPlaceholderMode::Masked);
wm.portalSetFieldStaticIpVisibility(PortalFieldVisibility::Auto);
wm.portalSetFieldStaticDnsVisibility(PortalFieldVisibility::Auto);
WiFiManagerParameter mqttHost(
"mqtt_host",
"MQTT host",
"broker.local",
64,
"placeholder='broker.local'"
);
wm.portalAddParameter(&mqttHost);
// Raw HTML remains first-class for custom parameters, but only inside the
// parameter-rendering surfaces (#/wifi or #/setup), not arbitrary portal regions.
WiFiManagerParameter gpsHelp(
"<div class='wm-callout wm-callout--info'>"
"<p>GPS options below apply only when a GPS module is connected.</p>"
"</div>"
);
wm.portalAddParameter(&gpsHelp);
PortalInfoSection battery;
battery.id = "battery";
battery.title = "Battery";
battery.items.push_back({"soc", "State of charge", "84%"});
battery.items.push_back({"voltage", "Voltage", "13.2V"});
wm.portalAddInfoSection(battery);
PortalHomeCard solar;
solar.id = "solar";
solar.title = "Solar summary";
solar.kind = PortalHomeCardKind::KeyValue;
solar.items.push_back({"pv", "PV input", "420W"});
solar.items.push_back({"load", "Load", "180W"});
wm.portalAddHomeCard(solar);
wm.portalAppendCss(
".wm-brand-logo svg{width:40px;height:40px;display:block;}"
".wm-hero-intro{max-width:42ch;}"
);
wm.portalAppendJs(
"document.addEventListener('wm:ready', function(e){"
" console.log('Portal booted', e.detail.boot);"
"});"
);
```
## WiFi Connect Status API
When `portalSetBehaviorConnectOnSave(true)` is enabled, saving WiFi credentials queues a station join and the SPA polls `GET /api/wifi/connect-status`.
Response shape:
```json
{
"state": "waiting | success | failed",
"message": "human readable status",
"wifiStatus": "WL_CONNECTED",
"stationIp": "192.168.1.42",
"redirectUrl": "http://192.168.1.42/"
void loop() {
wifi.process();
}
```
Notes:
When saved Wi-Fi is unavailable, `autoConnect()` starts the portal asynchronously. Call `process()` from every `loop()` iteration while it may be open. Flash [Basic Portal](examples/BasicPortal/) to try this exact flow; its README gives the network name, password, portal address, and expected result after saving Wi-Fi.
- `stationIp` and `redirectUrl` are present only after a successful station join.
- If the portal HTTP server is not on port `80`, `redirectUrl` includes the active port.
- On success, WiFiManager keeps the portal alive briefly so the client can read the success payload and navigate to the new device address before the captive AP is shut down.
- This improves the handoff on typical home networks, but it is not a universal guarantee: client captive-portal helpers, browser behavior, DHCP timing, and network isolation can still affect whether the redirect completes automatically.
## Make it yours
## Dependencies
```cpp
const char kTitle[] PROGMEM = "Set up Temperature Monitor";
const char kBrand[] PROGMEM = "Example Devices";
const char kAccent[] PROGMEM = "#347a45";
This fork depends on **DFTE** ([Device Framework Template Engine](https://github.com/alexhopeoconnor/DFTE)) and **ESP32Async/ESPAsyncWebServer**.
void setup() {
WiFiManagerPortalConfig portalUI;
For this repo's `platformio.ini` test setup, DFTE is expected as a sibling checkout:
// Leave fields unset to keep WiFiManager's built-in portal values.
portalUI.title = WiFiManagerPortalText::progmem(kTitle);
portalUI.identityText = WiFiManagerPortalText::progmem(kBrand);
```ini
lib_deps =
ESP32Async/ESPAsyncWebServer@3.9.1
symlink://../DFTE
// Theme values customise the built-in styles; they do not replace the portal.
portalUI.theme.accent = WiFiManagerPortalText::progmem(kAccent);
portalUI.theme.cornerRadiusPx = 10;
wifi.setPortalConfig(portalUI); // Apply before the portal starts.
wifi.autoConnect("Device Setup");
}
void loop() {
wifi.process();
}
```
## Installation
The [Branded Portal](examples/BrandedPortal/) example includes a static SVG, accessible identity text, and a small semantic theme. [Custom Portal Content](examples/CustomPortalContent/) shows the supported parameters, information sections, and home cards without replacing the portal shell.
## What it provides
- **Captive Wi-Fi setup:** starts an access point only when saved network credentials cannot connect.
- **Responsive portal:** one small portal for Wi-Fi, application fields, information, actions, and firmware update flow.
- **Structured APIs:** JSON endpoints under `/api/...` for applications that need portal state or connection results.
- **Product presentation:** title, logo, tagline, and semantic colour tokens without copying the portal HTML.
- **Primary and fallback networks:** an opt-in two-network controller backed by an application-provided store.
- **ESP8266 and ESP32 support:** the package resolves its asynchronous web dependencies for the selected target.
## Choose a guide
| 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 or flash a complete example | [Examples](examples/README.md) |
| Contribute to this fork or prepare a release | [Development](docs/DEVELOPMENT.md) |
## Install
```ini
[env:your_environment]
platform = espressif8266 ; or espressif32
board = d1_mini ; your board
framework = arduino
[common]
lib_deps =
https://github.com/alexhopeoconnor/WiFiManager.git
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.1
```
Or a local path:
The suffix after `#` is a Git ref. PlatformIO clones the repository and checks out that release tag; GitHub Release assets are unrelated. Arduino IDE users can install this repository as a library checkout.
```ini
lib_deps =
file:///path/to/WiFiManager
```
## AI Assistance Notice
Parts of this codebase have been developed and refactored with the aid of AI coding agents under human direction and review.
## License
See [LICENSE](LICENSE).
See the [documentation index](docs/README.md), [examples](examples/README.md), [release history](CHANGELOG.md), and [licence](LICENSE).
+30
View File
@@ -0,0 +1,30 @@
# 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.
Start a release with `bump-version.sh`. It updates package metadata and canonical installation snippets, then creates the changelog section. Replace its generated TODO with the release summary and update any behavioural documentation before running:
```bash
./scripts/bump-version.sh vMAJOR.MINOR.PATCH
# Replace the generated CHANGELOG TODO with the release summary.
./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).
+36
View File
@@ -0,0 +1,36 @@
# 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);
wifi.autoConnect("Example Setup", "change-me");
}
void loop() {
wifi.process(); // Keeps the portal responsive when setup is needed.
// 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, then returns `false`; call `process()` from `loop()` so that portal can run. Use a Wi-Fi-valid AP password.
## PlatformIO dependency
```ini
lib_deps =
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.1
```
The package includes the asynchronous web and TCP dependencies required by the selected ESP8266 or ESP32 target. Add WiFiManager as the application’s direct dependency; do not copy its internal dependency list into your project.
Next: build [Basic Portal](../examples/BasicPortal/), then explore [portal UI](PORTAL_UI.md) or [portal API](PORTAL_API.md).
Back to [documentation](README.md) · [project overview](../README.md).
+40
View File
@@ -0,0 +1,40 @@
# 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.
The current bootstrap contract is version 3. It includes `brand.tagline`, the short product tagline rendered in the portal header.
## 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. When connect-on-save is disabled, a saved configuration has no station address and the built-in portal remains open.
On success, the SPA first reads the station address, then POSTs `/api/wifi/connect-complete`. WiFiManager keeps the portal available for a fallback period while it waits for that acknowledgement, then closes it after a short grace delay so the normal device web server can start. This removes the old client-side race where the portal could disappear before the final status arrived. Captive-portal helpers, DHCP timing, browser behaviour, and network isolation can still prevent an automatic redirect, so clients must retain the visible address as a fallback.
## Portal timeout
When a configuration-portal timeout is enabled, `POST /api/portal/timeout-reset` restarts the countdown and returns `{ "ok": true, "timeoutSecondsRemaining": N }`. The built-in overview exposes this as an explicit reset control; custom clients may use the documented endpoint too.
## 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).
+78
View File
@@ -0,0 +1,78 @@
# 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 = "Example Devices";
const char kTagline[] PROGMEM = "Reliable setup for connected devices.";
const char kLogoAlt[] PROGMEM = "Example Devices";
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";
WiFiManagerPortalConfig kPortalUI;
}
void setup() {
// Set only the identity values this product needs.
kPortalUI.title = WiFiManagerPortalText::progmem(kTitle);
kPortalUI.identityText = WiFiManagerPortalText::progmem(kIdentity);
kPortalUI.tagline = WiFiManagerPortalText::progmem(kTagline);
kPortalUI.logo = WiFiManagerPortalAsset::svgFromProgmem(kLogo);
kPortalUI.logoAltText = WiFiManagerPortalText::progmem(kLogoAlt);
// Unset theme values retain the built-in style.
kPortalUI.theme.pageBackground = WiFiManagerPortalText::progmem(kPage);
kPortalUI.theme.surface = WiFiManagerPortalText::progmem(kSurface);
kPortalUI.theme.accent = WiFiManagerPortalText::progmem(kAccent);
kPortalUI.theme.accentText = WiFiManagerPortalText::progmem(kAccentText);
kPortalUI.theme.cornerRadiusPx = 10;
kPortalUI.theme.smallCornerRadiusPx = 6;
wifi.setPortalConfig(kPortalUI); // Configure before the portal starts.
wifi.autoConnect("Temperature Monitor");
}
void loop() { wifi.process(); }
```
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 concise setup-page heading |
| `identityText` | Company or product name in the header above navigation |
| `tagline` | Short tagline in the header above navigation |
| `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.
Back to the [documentation index](README.md) or [project overview](../README.md).
+12
View File
@@ -0,0 +1,12 @@
# WiFiManager documentation
| 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 or flash a complete example | [Examples](../examples/README.md) |
| Work on this fork or make a release | [Development and releases](DEVELOPMENT.md) |
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.
A WiFiManager application supplies its own durable store. The controller can also be used with an in-memory store for a temporary session, but credentials will not survive a restart.
## 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. The buildable [Station Profiles](../examples/StationProfiles/) example contains a compact EEPROM-backed store for both supported ESP targets.
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 the consuming application.
Back to [documentation](README.md) · [project overview](../README.md).
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

+17
View File
@@ -0,0 +1,17 @@
#include <Arduino.h>
#include <WiFiManager.h>
WiFiManager portal;
void setup() {
Serial.begin(115200);
portal.setConfigPortalTimeout(180);
if (portal.autoConnect("WiFiManager Basic", "example-pass")) {
Serial.println("Connected. Run your normal application here.");
} else {
Serial.println("Setup portal started at http://192.168.4.1/");
}
}
void loop() { portal.process(); }
+12
View File
@@ -0,0 +1,12 @@
# Basic Portal
This is the smallest useful WiFiManager application. It first tries the credentials the ESP platform already knows. If it cannot connect, it opens an access point named **WiFiManager Basic** with password **example-pass**.
1. Build and flash the selected `esp8266` or `esp32` environment.
2. Connect a phone or computer to **WiFiManager Basic**.
3. Open `http://192.168.4.1/` if your captive-portal helper does not open it automatically.
4. Select a network and save it. The board joins that network and the next reboot reconnects without opening the portal.
The access-point password is only an example. Choose a unique, Wi-Fi-valid password for a real product.
See the shared [example guide](../README.md) and [getting started](../../docs/GETTING_STARTED.md).
+26
View File
@@ -0,0 +1,26 @@
[platformio]
default_envs = esp8266
src_dir = .
[common]
framework = arduino
lib_ldf_mode = deep+
lib_deps =
WiFiManager=symlink://../..
[env:esp8266]
extends = common
platform = espressif8266
board = d1_mini
platform_packages =
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
-std=gnu++14
-DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
+47
View File
@@ -0,0 +1,47 @@
#include <Arduino.h>
#include <WiFiManager.h>
WiFiManager wifi;
namespace {
const char kPortalTitle[] PROGMEM = "Set up Temperature Monitor";
const char kPortalIdentity[] PROGMEM = "Example Devices";
const char kPortalTagline[] PROGMEM = "Reliable setup for connected devices.";
const char kPortalLogoAlt[] PROGMEM = "Example Devices";
const char kExampleLogo[] 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";
WiFiManagerPortalConfig kPortalUI;
}
void setup() {
Serial.begin(115200);
// Brand the built-in portal; unassigned fields retain their default values.
kPortalUI.title = WiFiManagerPortalText::progmem(kPortalTitle);
kPortalUI.identityText = WiFiManagerPortalText::progmem(kPortalIdentity);
kPortalUI.tagline = WiFiManagerPortalText::progmem(kPortalTagline);
kPortalUI.logo = WiFiManagerPortalAsset::svgFromProgmem(kExampleLogo);
kPortalUI.logoAltText = WiFiManagerPortalText::progmem(kPortalLogoAlt);
// Override only the theme tokens that define this product's appearance.
kPortalUI.theme.pageBackground = WiFiManagerPortalText::progmem(kPage);
kPortalUI.theme.surface = WiFiManagerPortalText::progmem(kSurface);
kPortalUI.theme.text = WiFiManagerPortalText::progmem(kText);
kPortalUI.theme.accent = WiFiManagerPortalText::progmem(kAccent);
kPortalUI.theme.accentText = WiFiManagerPortalText::progmem(kAccentText);
kPortalUI.theme.cornerRadiusPx = 10;
kPortalUI.theme.smallCornerRadiusPx = 6;
if (!wifi.setPortalConfig(kPortalUI)) {
Serial.println("Portal UI configuration was rejected");
}
wifi.setConfigPortalTimeout(180);
wifi.autoConnect("Temperature Monitor");
}
void loop() { wifi.process(); }
+9
View File
@@ -0,0 +1,9 @@
# Branded Portal
This example uses the supported `WiFiManagerPortalConfig` presentation API to give the built-in portal a product name, company identity, tagline, inline SVG mark, and semantic colour tokens.
Flash the `esp8266` or `esp32` environment, join **Temperature Monitor**, and open `http://192.168.4.1/`. The visual changes come from static firmware data; WiFiManager still owns the portal routes, forms, validation, and captive-network behaviour.
Use only trusted compiled SVG data. Keep the backing strings static for the lifetime of the firmware, then call `setPortalConfig()` before opening a portal.
See [Portal UI](../../docs/PORTAL_UI.md) and the shared [example guide](../README.md).
+26
View File
@@ -0,0 +1,26 @@
[platformio]
default_envs = esp8266
src_dir = .
[common]
framework = arduino
lib_ldf_mode = deep+
lib_deps =
WiFiManager=symlink://../..
[env:esp8266]
extends = common
platform = espressif8266
board = d1_mini
platform_packages =
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
-std=gnu++14
-DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
@@ -0,0 +1,29 @@
#include <Arduino.h>
#include <WiFiManager.h>
WiFiManager portal;
WiFiManagerParameter brokerHost("broker_host", "MQTT broker", "mqtt.local", 40);
void setup() {
Serial.begin(115200);
portal.portalAddParameter(&brokerHost);
portal.portalAddInfoSection({
"device", "Example device",
{{"firmware", "Firmware", "1.0.0"}, {"sensor", "Sensor", "Ready"}},
});
portal.portalAddHomeCard({
"hint", "What this example adds", PortalHomeCardKind::Callout,
"A normal text setting, a status section, and a home-page callout.", {},
});
portal.setSaveParamsCallback([](WiFiManager::WiFiManagerRequestArgs) {
Serial.print("MQTT broker selected: ");
Serial.println(brokerHost.getValue());
});
portal.setConfigPortalTimeout(180);
portal.autoConnect("WiFiManager Content", "example-pass");
}
void loop() { portal.process(); }
+13
View File
@@ -0,0 +1,13 @@
# Custom Portal Content
This example keeps WiFiManager’s portal navigation, validation, and captive behaviour, while adding three application-owned pieces of content:
- an editable MQTT broker host field;
- a compact device-information section;
- a callout on the portal overview.
It opens **WiFiManager Content** with password **example-pass** until it has working station credentials. Save the form, then inspect serial output to see the selected broker value.
The parameter object is global because WiFiManager reads it for the lifetime of the portal. In a real application, copy the value into that application’s own validated persistent configuration inside the save callback.
See [Portal UI](../../docs/PORTAL_UI.md) and the shared [example guide](../README.md).
@@ -0,0 +1,26 @@
[platformio]
default_envs = esp8266
src_dir = .
[common]
framework = arduino
lib_ldf_mode = deep+
lib_deps =
WiFiManager=symlink://../..
[env:esp8266]
extends = common
platform = espressif8266
board = d1_mini
platform_packages =
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
-std=gnu++14
-DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
+22
View File
@@ -0,0 +1,22 @@
# WiFiManager examples
Every directory below is a standalone PlatformIO project. Build it from the repository root or the example directory:
```bash
pio run -d examples/BasicPortal -e esp8266
pio run -d examples/BasicPortal -e esp8266 -t upload
pio device monitor -d examples/BasicPortal -e esp8266
```
Choose `esp32` for an ESP32 development board. The examples use the checked-out WiFiManager source, so they are also useful while developing this fork.
| Example | Start here when you want to… |
| --- | --- |
| [Basic Portal](BasicPortal/) | provision one board through the normal saved-network-or-portal flow |
| [Custom Portal Content](CustomPortalContent/) | add application settings and useful status cards to the built-in portal |
| [Branded Portal](BrandedPortal/) | change the portal’s identity, logo, and semantic visual theme |
| [Station Profiles](StationProfiles/) | remember a primary Wi-Fi network and one fallback in application-owned EEPROM storage |
The portal examples intentionally begin with no station credentials. On first boot, connect to the access point printed on serial and open `http://192.168.4.1/`.
Back to the [project overview](../README.md).
+9
View File
@@ -0,0 +1,9 @@
# Station Profiles
This example gives WiFiManager a small application-owned EEPROM store. The portal accepts a required primary Wi-Fi network and an optional fallback, verifies a submitted network before saving it, then remembers the last successful choice across restarts.
On a blank board, connect to **WiFiManager Profiles** with password **example-pass** and open `http://192.168.4.1/`. Enter a primary network and, if useful, one fallback. After a successful connection, restart the board to confirm that it tries the saved profiles before reopening the portal.
`StoredProfiles` is intentionally simple so the ownership boundary is visible. A production application should add its own record versioning and integrity protection around the application’s complete configuration; WiFiManager only owns network-selection policy.
See [Station profiles](../../docs/STATION_PROFILES.md) and the shared [example guide](../README.md).
@@ -0,0 +1,63 @@
#include <Arduino.h>
#include <EEPROM.h>
#include <WiFiManager.h>
namespace {
constexpr uint32_t kStoreMagic = 0x574D5031; // "WMP1"
struct StoredProfiles {
uint32_t magic;
WiFiManagerStationProfiles profiles;
};
class EepromProfileStore final : public WiFiManagerStationProfileStore {
public:
bool begin() {
#if defined(ESP32)
return EEPROM.begin(sizeof(StoredProfiles));
#else
EEPROM.begin(sizeof(StoredProfiles));
return true;
#endif
}
bool load(WiFiManagerStationProfiles& profiles) override {
StoredProfiles stored{};
EEPROM.get(0, stored);
if (stored.magic != kStoreMagic) {
return false;
}
profiles = stored.profiles;
return true;
}
bool save(const WiFiManagerStationProfiles& profiles) override {
EEPROM.put(0, StoredProfiles{kStoreMagic, profiles});
return EEPROM.commit();
}
bool clear() override {
EEPROM.put(0, StoredProfiles{});
return EEPROM.commit();
}
};
EepromProfileStore profileStore;
WiFiManager portal;
} // namespace
void setup() {
Serial.begin(115200);
if (!profileStore.begin()) {
Serial.println("Could not initialise EEPROM profile storage");
return;
}
portal.setStationProfileStore(&profileStore);
portal.setStationRecoveryInterval(30000);
portal.startStationConnection("WiFiManager Profiles", "example-pass");
}
void loop() {
portal.process();
}
+26
View File
@@ -0,0 +1,26 @@
[platformio]
default_envs = esp8266
src_dir = .
[common]
framework = arduino
lib_ldf_mode = deep+
lib_deps =
WiFiManager=symlink://../..
[env:esp8266]
extends = common
platform = espressif8266
board = d1_mini
platform_packages =
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
-std=gnu++14
-DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
+197 -91
View File
@@ -1,9 +1,9 @@
/**
* WiFiManager.h
*
*
* WiFiManager, a library for the ESP8266/Arduino platform
* for configuration of WiFi credentials using a Captive Portal
*
*
* @author Creator tzapu
* @author tablatronix
* @author alexhopeoconnor
@@ -22,6 +22,7 @@
#include <vector>
#include <functional>
#include <string.h>
#include "WiFiManagerParameter.h"
#include "WiFiManagerLogLevel.h"
@@ -39,13 +40,13 @@
// #define WM_DFTE_LOGGING // opt-in: bridge DFTE logging into WiFiManager (see README)
// #define WM_MDNS // includes MDNS, also set MDNS with sethostname
// #define WM_FIXERASECONFIG // use erase flash fix
// #define WM_ERASE_NVS // esp32 erase(true) will erase NVS
// #define WM_ERASE_NVS // esp32 erase(true) will erase NVS
// #define WM_RTC // esp32 info page will include reset reasons
// #define WIFI_MANAGER_OVERRIDE_STRINGS // build flag for using own strings include
#ifdef ARDUINO_ESP8266_RELEASE_2_3_0
#warning "ARDUINO_ESP8266_RELEASE_2_3_0, some WM features disabled"
#warning "ARDUINO_ESP8266_RELEASE_2_3_0, some WM features disabled"
// @todo check failing on platform = espressif8266@1.7.3
#define WM_NOASYNC // esp8266 no async scan wifi
#define WM_NOCOUNTRY // esp8266 no country
@@ -91,16 +92,16 @@
#include <ESP8266mDNS.h>
#endif
#define WIFI_getChipId() ESP.getChipId()
#define WIFI_getChipId() ESP.getChipId()
#define WM_WIFIOPEN ENC_TYPE_NONE
#elif defined(ESP32)
#include <WiFi.h>
#include <esp_wifi.h>
#include <esp_wifi.h>
#include <Update.h>
#include <AsyncTCP.h>
#define WIFI_getChipId() (uint32_t)ESP.getEfuseMac()
#define WM_WIFIOPEN WIFI_AUTH_OPEN
@@ -141,13 +142,46 @@
#include <string>
// Include utility functions
#include "WiFiManagerUtils.h"
#include "WiFiManagerPortalUI.h"
// A station profile is intentionally fixed-size. ESP Wi-Fi accepts one station
// configuration at a time, so multi-network behaviour belongs to the
// application/controller rather than the SDK's saved station configuration.
constexpr uint8_t WM_STATION_PROFILE_COUNT = 2;
constexpr uint8_t WM_NO_STATION_PROFILE = 0xFF;
struct WiFiManagerStationProfile {
bool enabled = false;
bool hasPassword = false;
char ssid[33] = {};
char password[65] = {};
};
struct WiFiManagerStationProfiles {
WiFiManagerStationProfile slots[WM_STATION_PROFILE_COUNT] = {};
uint8_t preferredSlot = 0;
uint8_t lastSuccessfulSlot = WM_NO_STATION_PROFILE;
};
/**
* Optional durable backing for multi-profile station credentials. The manager
* owns profile policy and never owns this store. A consumer that needs durable
* profiles supplies one; otherwise the profiles remain in RAM.
*/
class WiFiManagerStationProfileStore {
public:
virtual bool load(WiFiManagerStationProfiles& profiles) = 0;
virtual bool save(const WiFiManagerStationProfiles& candidate) = 0;
virtual bool clear() = 0;
virtual ~WiFiManagerStationProfileStore() = default;
};
// prep string concat vars
#define WM_STRING2(x) #x
#define WM_STRING(x) WM_STRING2(x)
// WiFiManager version
const char WM_VERSION_STR[] PROGMEM = "v2.0.19";
const char WM_VERSION_STR[] PROGMEM = "v3.1.0";
// #include <esp_idf_version.h>
#ifdef ESP_IDF_VERSION
@@ -155,7 +189,7 @@ const char WM_VERSION_STR[] PROGMEM = "v2.0.19";
// #pragma message "ESP_IDF_VERSION_MINOR = " WM_STRING(ESP_IDF_VERSION_MINOR)
// #pragma message "ESP_IDF_VERSION_PATCH = " WM_STRING(ESP_IDF_VERSION_PATCH)
#define VER_IDF_STR WM_STRING(ESP_IDF_VERSION_MAJOR) "." WM_STRING(ESP_IDF_VERSION_MINOR) "." WM_STRING(ESP_IDF_VERSION_PATCH)
#else
#else
#define VER_IDF_STR "Unknown"
#endif
@@ -184,7 +218,7 @@ const char WM_VERSION_STR[] PROGMEM = "v2.0.19";
#define VER_ARDUINO_STR "Unknown"
#endif
#endif
#else
#else
#define VER_ARDUINO_STR "Unknown"
#endif
@@ -232,10 +266,11 @@ struct PortalHomeCard {
};
struct PortalBrandState {
String title = "WiFiManager";
String identityTextOverride;
String homeIntro;
String logoSvg;
WiFiManagerPortalText title;
WiFiManagerPortalText identityTextOverride;
WiFiManagerPortalText tagline;
WiFiManagerPortalAsset logo;
WiFiManagerPortalText logoAltText;
};
struct PortalPageState {
@@ -257,12 +292,6 @@ struct PortalLayoutState {
bool paramsOnWifiPage = true;
};
struct PortalAssetState {
String appendedCss;
String overriddenCss;
String appendedJs;
};
struct PortalStructuredExtrasState {
std::vector<PortalInfoSection> infoSections;
std::vector<PortalHomeCard> homeCards;
@@ -327,6 +356,27 @@ class WiFiManager
WM_CP_CONNECT_FAILED,
};
enum wm_station_state_t : uint8_t {
WM_STATION_IDLE = 0,
WM_STATION_LOADING,
WM_STATION_ATTEMPTING,
WM_STATION_SWITCHING,
WM_STATION_CONNECTED,
WM_STATION_BACKOFF,
WM_STATION_PORTAL,
};
struct wm_station_status_t {
wm_station_state_t state = WM_STATION_IDLE;
uint8_t activeSlot = WM_NO_STATION_PROFILE;
uint8_t attemptedSlot = WM_NO_STATION_PROFILE;
uint8_t configuredProfiles = 0;
uint8_t wifiStatus = WL_IDLE_STATUS;
bool lastConnectionWasCandidate = false;
bool storageSaveFailed = false;
String message = "Idle";
};
/** Optional notification hook; prefer getters for consumers. */
enum wm_event_t : uint8_t {
WM_EVENT_PORTAL_STARTED = 0,
@@ -334,10 +384,16 @@ class WiFiManager
WM_EVENT_PORTAL_CONNECT_QUEUED,
WM_EVENT_PORTAL_CONNECT_START,
WM_EVENT_PORTAL_CONNECT_SUCCESS,
WM_EVENT_PORTAL_CONNECT_FAILED
WM_EVENT_PORTAL_CONNECT_FAILED,
WM_EVENT_STATION_PROFILE_ATTEMPT,
WM_EVENT_STATION_PROFILE_CONNECTED,
WM_EVENT_STATION_PROFILE_FAILED,
WM_EVENT_STATION_LINK_LOST,
WM_EVENT_STATION_BACKOFF,
WM_EVENT_STATION_PROFILES_CLEARED
};
using WiFiManagerEventCallback = std::function<void(wm_event_t)>;
WiFiManager(Print& consolePort);
WiFiManager();
~WiFiManager();
@@ -347,14 +403,28 @@ class WiFiManager
boolean autoConnect();
boolean autoConnect(char const *apName, char const *apPassword = NULL);
// Fixed two-profile station mode. When a store is attached, WiFiManager
// owns profile selection and explicit connection attempts rather than the
// platform's one saved station configuration.
void setStationProfileStore(WiFiManagerStationProfileStore* store);
bool startStationConnection(char const *apName = NULL, char const *apPassword = NULL);
bool startStationCandidate(const WiFiManagerStationProfiles& candidate);
bool startStationCandidate(const WiFiManagerStationProfiles& candidate, char const *apName, char const *apPassword = NULL);
bool saveStationProfiles(const WiFiManagerStationProfiles& profiles);
void clearStationProfiles();
bool isStationProfileMode() const;
void setStationRecoveryInterval(unsigned long intervalMs);
const WiFiManagerStationProfiles& getStationProfiles() const;
const wm_station_status_t& getStationStatus() const;
//manually start the config portal, autoconnect does this automatically on connect failure
void startConfigPortal(); // auto generates apname
void startConfigPortal(char const *apName, char const *apPassword = NULL);
//manually stop the config portal - immediately shuts down the portal
void stopConfigPortal();
//manually start the web portal, autoconnect does this automatically on connect failure
//manually start the web portal, autoconnect does this automatically on connect failure
void startWebPortal();
//manually stop the web portal if started manually
@@ -438,13 +508,13 @@ class WiFiManager
// sets number of retries for autoconnect, force retry after wait failure exit
void setConnectRetries(uint8_t numRetries); // default 1
//sets timeout for which to attempt connecting on saves, useful if there are bugs in esp waitforconnectloop
void setSaveConnectTimeout(unsigned long seconds);
// lets you disable automatically connecting after save from webportal
void setSaveConnect(bool connect = true);
void setLogEnabled(boolean enabled);
void setLogPrefix(String prefix);
void setLogOutput(boolean enabled, WiFiManagerLogLevel maxLevel);
@@ -463,44 +533,44 @@ class WiFiManager
//set min quality percentage to include in scan, defaults to 8% if not specified
void setMinimumSignalQuality(int quality = 8);
//sets a custom ip /gateway /subnet configuration
void setAPStaticIPConfig(IPAddress ip, IPAddress gw, IPAddress sn);
//sets config for a static IP
void setSTAStaticIPConfig(IPAddress ip, IPAddress gw, IPAddress sn);
//sets config for a static IP with DNS
void setSTAStaticIPConfig(IPAddress ip, IPAddress gw, IPAddress sn, IPAddress dns);
//if this is set, it will exit after config, even if connection is unsuccessful.
void setBreakAfterConfig(boolean shouldBreak);
//if this is true, remove duplicated Access Points - defaut true
void setRemoveDuplicateAPs(boolean removeDuplicates);
//setter for ESP wifi.persistent so we can remember it and restore user preference, as WIFi._persistent is protected
void setRestorePersistent(boolean persistent);
//if true, always show static net inputs, IP, subnet, gateway, else only show if set via setSTAStaticIPConfig
void setShowStaticFields(boolean alwaysShow);
//if true, always show static dns, esle only show if set via setSTAStaticIPConfig
void setShowDnsFields(boolean alwaysShow);
//if false, timeout captive portal even if a STA client connected to softAP (false), suggest disabling if captiveportal is open
void setAPClientCheck(boolean enabled);
//if true, reset timeout when webclient connects (true), suggest disabling if captiveportal is open
//if true, reset timeout when webclient connects (true), suggest disabling if captiveportal is open
void setWebPortalClientCheck(boolean enabled);
// if true, enable autoreconnecting
void setWiFiAutoReconnect(boolean enabled);
// if true, wifiscan will show percentage instead of quality icons, until we have better templating
void setScanDispPerc(boolean enabled);
// if true (default) then start the config portal from autoConnect if connection failed
void setEnableConfigPortal(boolean enable);
@@ -513,18 +583,17 @@ class WiFiManager
// set ap channel
void setWiFiAPChannel(int32_t channel);
// set ap hidden
void setWiFiAPHidden(bool hidden); // default false
// clean connect, always disconnect before connecting
void setCleanConnect(bool enable); // default false
// ---- Portal (all customization entry points use the portal* prefix) ----
void portalSetBrandTitle(const String& title);
void portalSetContextIdentityText(const String& identityText);
void portalSetBrandHomeIntro(const String& text);
void portalSetBrandLogoSvg(const String& svgMarkup);
// ---- Portal presentation ----
// Apply this before a portal is started. The portal response model is
// immutable while active so async responses never observe partial UI state.
bool setPortalConfig(const WiFiManagerPortalConfig& config);
void portalSetPageInfoVisible(bool visible);
void portalSetPageUpdateVisible(bool visible);
@@ -557,16 +626,12 @@ class WiFiManager
void portalAddHomeCard(const PortalHomeCard& card);
void portalClearHomeCards();
void portalAppendCss(const String& css);
void portalOverrideCss(const String& css);
void portalAppendJs(const String& js);
// get last connection result, including autoconnect and portal credential-save attempts
uint8_t getLastConxResult();
// get a status as string
String getWLStatusString(uint8_t status);
String getWLStatusString();
String getWLStatusString(uint8_t status);
String getWLStatusString();
// get wifi mode as string
String getModeString(uint8_t mode);
@@ -574,7 +639,7 @@ class WiFiManager
// check if the module has a saved ap to connect to
bool getWiFiIsSaved();
// helper to get saved password, if persistent get stored, else get current if connected
// helper to get saved password, if persistent get stored, else get current if connected
String getWiFiPass(bool persistent = true);
// helper to get saved ssid, if persistent get stored, else get current if connected
@@ -588,17 +653,17 @@ class WiFiManager
// helper for html
String htmlEntities(String str, bool whitespace = false);
// set the country code for wifi settings, CN
void setCountry(String cc);
// get default ap esp uses , esp_chipid etc
String getDefaultAPName();
// set the WiFi SSID prefix for default AP name, default platform-specific (ESP/ESP32/WM)
void setWiFiSSIDPrefix(String prefix);
// set port of webserver, 80
void setHttpPort(uint16_t port);
@@ -615,7 +680,7 @@ class WiFiManager
uint8_t getConfigPortalConnectStatus() const;
String getConfigPortalConnectMessage() const;
void setEventCallback(WiFiManagerEventCallback cb);
// check if web portal is active (true)
bool getWebPortalActive();
@@ -624,10 +689,10 @@ class WiFiManager
// get hostname helper
String getWiFiHostname();
// get server instance (for testing)
AsyncWebServer* getServer();
// get DNS server instance (for testing)
DNSServer* getDNSServer();
@@ -636,7 +701,7 @@ class WiFiManager
class WiFiManagerRequestArgs {
public:
std::unordered_map<std::string, std::string> args;
// Constructor - builds from AsyncWebServerRequest
WiFiManagerRequestArgs(AsyncWebServerRequest* request) {
if (request) {
@@ -647,19 +712,19 @@ class WiFiManager
}
}
}
// Default constructor for tests and manually assembled argument sets
WiFiManagerRequestArgs() {}
// Check if argument exists
bool hasArg(const char* name) const {
return args.find(std::string(name)) != args.end();
}
bool hasArg(const String& name) const {
return hasArg(name.c_str());
}
// Get argument value as String
String getArg(const char* name, const String& defaultValue = "") const {
auto it = args.find(std::string(name));
@@ -668,29 +733,29 @@ class WiFiManager
}
return defaultValue;
}
String getArg(const String& name, const String& defaultValue = "") const {
return getArg(name.c_str(), defaultValue);
}
// Type conversion helpers
int getArgAsInt(const char* name, int defaultValue = 0) const {
String value = getArg(name);
return value.length() > 0 ? value.toInt() : defaultValue;
}
float getArgAsFloat(const char* name, float defaultValue = 0.0f) const {
String value = getArg(name);
return value.length() > 0 ? value.toFloat() : defaultValue;
}
bool getArgAsBool(const char* name, bool defaultValue = false) const {
String value = getArg(name);
if (value.length() == 0) return defaultValue;
return value == "1" || value.equalsIgnoreCase("true") ||
return value == "1" || value.equalsIgnoreCase("true") ||
value.equalsIgnoreCase("on") || value.equalsIgnoreCase("yes");
}
size_t count() const {
return args.size();
}
@@ -723,7 +788,7 @@ class WiFiManager
WiFiScanRuntimeState _scan;
std::vector<WiFiScanNetwork> _scanResultsCache;
bool _scanLifecycleBlocked = false;
// async reboot/abort scheduling
bool _rebootScheduled = false; // flag for scheduled reboot
unsigned long _rebootTime = 0; // ms when reboot should occur
@@ -742,13 +807,30 @@ class WiFiManager
String _defaultssid = ""; // preload ssid
String _defaultpass = ""; // preload pass
WiFiManagerStationProfileStore* _stationProfileStore = nullptr;
WiFiManagerStationProfiles _stationProfiles;
WiFiManagerStationProfiles _stationCandidate;
wm_station_status_t _stationStatus;
bool _stationProfilesLoaded = false;
bool _stationCandidateActive = false;
bool _stationCandidateFromPortal = false;
bool _stationEverConnected = false;
uint8_t _stationAttemptMask = 0;
uint8_t _stationPendingSlot = WM_NO_STATION_PROFILE;
unsigned long _stationNextAttemptAt = 0;
unsigned long _stationAttemptStartedAt = 0;
unsigned long _stationBackoffStartedAt = 0;
unsigned long _stationRecoveryInterval = 5000UL;
String _stationPortalApName = "";
String _stationPortalApPassword = "";
// options flags
unsigned long _configPortalTimeout = 0; // ms close config portal loop if set (depending on _cp/webClientCheck options)
unsigned long _connectTimeout = 0; // ms stop trying to connect to ap if set
unsigned long _saveTimeout = 0; // ms stop trying to connect to ap on saves, in case bugs in esp waitforconnectresult
WiFiMode_t _usermode = WIFI_STA; // Default user mode
String _wifissidprefix =
String _wifissidprefix =
#ifdef ESP8266
"ESP"
#elif defined(ESP32)
@@ -799,16 +881,21 @@ class WiFiManager
boolean _disableConfigPortal = true; // FOR autoconnect - stop config portal if cp wifi save
String _hostname = ""; // hostname for esp8266 for dhcp, and or MDNS
// Grouped portal presentation / customization (see portal* setters; JSON in v2 bootstrap)
// Grouped portal presentation / customization (see portal APIs and JSON bootstrap)
PortalBrandState _portalBrand;
WiFiManagerPortalTheme _portalTheme;
String _portalThemeStyle;
PortalPageState _portalPages;
PortalActionState _portalActions;
PortalLayoutState _portalLayout;
PortalAssetState _portalAssets;
PortalStructuredExtrasState _portalStructured;
bool canChangePortalPresentation() const;
bool isPortalThemeValid(const WiFiManagerPortalTheme& theme) const;
void rebuildPortalThemeStyle();
// internal options
// wifiscan notes
// currently disabled due to issues with caching, sometimes first scan is empty esp32 wifi not init yet race, or portals hit server nonstop flood
// The following are background wifi scanning optimizations
@@ -819,22 +906,22 @@ class WiFiManager
// async enables asyncronous scans, so they do not block anything
// the refresh button bypasses cache
// no aps found is problematic as scans are always going to want to run, leading to page load delays
//
//
// These settings really only make sense with _preloadwifiscan true
// but not limited to, we could run continuous background scans on various page hits, or xhr hits
// which would be better coupled with asyncscan
// atm preload is only done on root hit and startcp
//
//
// preload scanning causes AP to delay showing for users, but also caches and lets the cp load faster once its open
// my scan takes 7-10 seconds
public:
boolean _preloadwifiscan = false; // preload wifiscan if true
boolean _preloadwifiscan = true; // begin one asynchronous scan as the portal starts
unsigned int _scancachetime = 30000; // ms cache time for preload scans
protected:
boolean _autoforcerescan = false; // automatically force rescan if scan networks is 0, ignoring cache
boolean _disableIpFields = false; // modify function of setShow_X_Fields(false), forces ip fields off instead of default show if set, eg. _staShowStaticFields=-1
String _wificountry = ""; // country code, @todo define in strings lang
@@ -848,7 +935,7 @@ protected:
void setupConfigPortal();
bool shutdownConfigPortal();
bool setupHostname(bool restart);
#ifdef NO_EXTRA_4K_HEAP
boolean _tryWPS = false; // try WPS on save failure, unsupported
void startWPS();
@@ -869,6 +956,24 @@ protected:
bool wifiConnectDefault();
bool wifiConnectNew(String ssid, String pass,bool connect = true);
void processStationController();
bool beginStationProfile(uint8_t slot);
void queueStationProfile(uint8_t slot);
void beginStationCycle(bool preferLastSuccessful);
void handleStationAttemptFailure(uint8_t status, const String& message);
void handleStationConnectionSuccess();
void completePortalStationAttempt(bool success, uint8_t status, const String& message);
void acknowledgePortalConnectHandoff();
void enterStationPortal();
bool hasUsableStationConnection() const;
bool isStationProfileEnabled(const WiFiManagerStationProfiles& profiles, uint8_t slot) const;
uint8_t configuredStationProfileCount(const WiFiManagerStationProfiles& profiles) const;
uint8_t chooseStationProfile(const WiFiManagerStationProfiles& profiles, bool preferLastSuccessful) const;
const WiFiManagerStationProfiles& stationProfilesForAttempt() const;
WiFiManagerStationProfiles& stationProfilesForAttempt();
bool validateStationProfiles(const WiFiManagerStationProfiles& profiles) const;
unsigned long stationAttemptTimeout() const;
uint8_t waitForConnectResult();
uint8_t waitForConnectResult(uint32_t timeout);
void updateConxResult(uint8_t status);
@@ -931,12 +1036,12 @@ protected:
#define WM_DISCONWORKAROUND
#endif
#else
#else
#define WM_NOCOUNTRY
#endif
#ifdef WM_NOCOUNTRY
#warning "ESP32 set country unavailable"
#warning "ESP32 set country unavailable"
#endif
@@ -979,6 +1084,7 @@ protected:
_cpConnectStationIp = stationIp;
_cpConnectStatus = status;
}
void wmTestCompleteProfilePortalConnectionSuccess();
void wmTestSetPortalConnectFailure(const String& message, uint8_t status = WL_CONNECT_FAILED) {
_cpConnectState = wm_cp_connect_state_t::failed;
_cpConnectMessage = message;
@@ -1042,9 +1148,9 @@ protected:
// if we decide to support this, these checks will need to be replaced with something client aware to check if client origin is ap or web
// These state checks are critical and used for internal function checks
boolean webPortalActive = false;
boolean storeSTAmode = true; // option store persistent STA mode in connectwifi
boolean storeSTAmode = true; // option store persistent STA mode in connectwifi
int timer = 0; // timer for debug throttle for numclients, and portal timeout messages
// WiFiManagerParameter
int _paramsCount = 0;
int _max_params;
@@ -55,6 +55,8 @@ class WiFiManagerHandlers {
void handleApiWifiMeta(AsyncWebServerRequest *request);
void handleApiWifiSave(AsyncWebServerRequest *request);
void handleApiWifiConnectStatus(AsyncWebServerRequest *request);
void handleApiWifiConnectComplete(AsyncWebServerRequest *request);
void handleApiPortalTimeoutReset(AsyncWebServerRequest *request);
void handleApiParamsGet(AsyncWebServerRequest *request);
void handleApiParamsSave(AsyncWebServerRequest *request);
void handleApiInfo(AsyncWebServerRequest *request);
@@ -101,11 +103,11 @@ class WiFiManagerHandlers {
void appendVisibleScanResultsJson(String& json, const std::vector<const WiFiManager::WiFiScanNetwork*>& networks);
void applyWifiAndParamsFromRequest(AsyncWebServerRequest *request);
bool buildStationProfilesFromRequest(AsyncWebServerRequest *request, WiFiManagerStationProfiles& profiles);
void buildPlainStatusSummary(String& out);
void appendPortalJsonStaticFields(String& json, bool& first);
void appendPortalJsonCustomParams(String& json, bool& first);
String composePortalStylesheet() const;
void appendPortalExtraInfoSectionsJson(String& json, bool& first);
void appendPortalExtraHomeCardsJson(String& json, bool& first);
@@ -0,0 +1,93 @@
/**
* WiFiManagerPortalUI.h
*
* Deliberate, portal-specific presentation configuration. This header does
* not depend on DeviceFramework: WiFiManager remains useful on its own.
*/
#ifndef WIFI_MANAGER_PORTAL_UI_H
#define WIFI_MANAGER_PORTAL_UI_H
#include <Arduino.h>
enum class WiFiManagerPortalStorage : uint8_t {
Ram,
Progmem,
};
/** Non-owning static text. The caller retains the data for the firmware lifetime. */
struct WiFiManagerPortalText {
const char* data = nullptr;
WiFiManagerPortalStorage storage = WiFiManagerPortalStorage::Ram;
static constexpr WiFiManagerPortalText ram(const char* value) {
return {value, WiFiManagerPortalStorage::Ram};
}
static constexpr WiFiManagerPortalText progmem(const char* value) {
return {value, WiFiManagerPortalStorage::Progmem};
}
bool empty() const { return data == nullptr || length() == 0; }
size_t length() const {
return data == nullptr ? 0 : (storage == WiFiManagerPortalStorage::Progmem ? strlen_P(data) : strlen(data));
}
char at(size_t index) const {
return storage == WiFiManagerPortalStorage::Progmem
? static_cast<char>(pgm_read_byte(data + index))
: data[index];
}
};
/** Optional inline SVG branding asset. It is not a general HTML extension point. */
struct WiFiManagerPortalAsset {
WiFiManagerPortalText svg;
static constexpr WiFiManagerPortalAsset svgFromRam(const char* value) {
return {WiFiManagerPortalText::ram(value)};
}
static constexpr WiFiManagerPortalAsset svgFromProgmem(const char* value) {
return {WiFiManagerPortalText::progmem(value)};
}
bool empty() const { return svg.empty(); }
};
/**
* Semantic colour and shape values for WiFiManager's built-in portal.
*
* Leave a value empty to retain the built-in stylesheet value. Values are
* validated before they are emitted into the portal stylesheet; they are not
* a general CSS injection mechanism.
*/
struct WiFiManagerPortalTheme {
WiFiManagerPortalText pageBackground;
WiFiManagerPortalText surface;
WiFiManagerPortalText text;
WiFiManagerPortalText mutedText;
WiFiManagerPortalText border;
WiFiManagerPortalText accent;
WiFiManagerPortalText accentHover;
WiFiManagerPortalText accentText;
WiFiManagerPortalText danger;
WiFiManagerPortalText dangerHover;
WiFiManagerPortalText success;
uint8_t cornerRadiusPx = 0;
uint8_t smallCornerRadiusPx = 0;
};
/**
* Complete setup-time presentation configuration for WiFiManager's portal.
*
* Text and SVG assets are non-owning static data in RAM or PROGMEM. Apply this
* before starting a portal; asynchronous portal responses use immutable state.
*/
struct WiFiManagerPortalConfig {
WiFiManagerPortalText title;
WiFiManagerPortalText identityText;
WiFiManagerPortalText tagline;
WiFiManagerPortalAsset logo;
WiFiManagerPortalText logoAltText;
WiFiManagerPortalTheme theme;
};
#endif // WIFI_MANAGER_PORTAL_UI_H
@@ -15,6 +15,8 @@
* GET /api/wifi/meta
* POST /api/wifi/save
* GET /api/wifi/connect-status
* POST /api/wifi/connect-complete
* POST /api/portal/timeout-reset
* GET /api/params
* POST /api/params/save
* GET /api/info
@@ -55,6 +57,8 @@ const char R_api_wifi_scan[] PROGMEM = "/api/wifi/scan";
const char R_api_wifi_meta[] PROGMEM = "/api/wifi/meta";
const char R_api_wifi_save[] PROGMEM = "/api/wifi/save";
const char R_api_wifi_connect_status[] PROGMEM = "/api/wifi/connect-status";
const char R_api_wifi_connect_complete[] PROGMEM = "/api/wifi/connect-complete";
const char R_api_portal_timeout_reset[] PROGMEM = "/api/portal/timeout-reset";
const char R_api_params[] PROGMEM = "/api/params";
const char R_api_params_save[] PROGMEM = "/api/params/save";
const char R_api_info[] PROGMEM = "/api/info";
+39 -13
View File
@@ -23,6 +23,7 @@ const char CSS_STYLE[] PROGMEM = "<style>"
"--wm-border:#e2e8f0;"
"--wm-brand:#0ea5e9;"
"--wm-brand-hover:#0284c7;"
"--wm-brand-text:#ffffff;"
"--wm-danger:#dc2626;"
"--wm-danger-hover:#b91c1c;"
"--wm-success:#16a34a;"
@@ -73,19 +74,21 @@ const char CSS_STYLE[] PROGMEM = "<style>"
".wm-icon-label{display:inline-flex;align-items:center;gap:8px;min-width:0}"
".wm-icon{display:inline-flex;align-items:center;justify-content:center;width:1.1rem;height:1.1rem;flex:0 0 1.1rem;color:currentColor}"
".wm-icon svg{display:block;width:100%;height:100%}"
/* Hero / page chrome */
".wm-hero{margin-bottom:20px}"
".wm-hero-title{font-size:1.65rem;line-height:1.2;margin-bottom:.25em}"
".wm-hero-sub{font-size:1rem;color:var(--wm-muted);font-weight:500;margin:0}"
".wm-brand-logo{margin-bottom:12px;line-height:0}"
".wm-brand-logo svg{display:block;max-width:160px;height:auto}"
".wm-hero-intro{font-size:.95rem;color:var(--wm-muted);margin:.5em 0 0;line-height:1.45;max-width:42ch}"
/* Header / page chrome */
".wm-site-header{display:flex;align-items:center;gap:10px;min-height:40px;margin:0 0 12px}"
".wm-site-logo{display:flex;align-items:center;justify-content:center;width:40px;height:40px;flex:0 0 40px;line-height:0}"
".wm-site-logo svg{display:block;width:100%;height:100%}"
".wm-site-brand-copy{min-width:0}"
".wm-site-brand-name{margin:0;font-size:.78rem;font-weight:800;letter-spacing:.09em;text-transform:uppercase;color:var(--wm-text)}"
".wm-site-tagline{margin:2px 0 0;font-size:.82rem;line-height:1.25;color:var(--wm-muted)}"
".wm-page-head{margin-bottom:16px}"
".wm-page-head h1{font-size:1.45rem}"
".wm-page-desc{font-size:.95rem;color:var(--wm-muted);margin:.35em 0 0}"
/* Cards */
".wm-card{background:var(--wm-surface);border:1px solid var(--wm-border);border-radius:var(--wm-radius);"
"box-shadow:var(--wm-shadow);padding:18px 16px;margin-bottom:16px;text-align:left}"
".wm-scan-card,.wm-ota-card{position:relative;overflow:hidden}"
".wm-scan-results{min-height:126px}"
".wm-card-title{font-size:1rem;margin:0 0 12px;padding-bottom:10px;border-bottom:1px solid var(--wm-border);"
"color:var(--wm-text)}"
".wm-card-subtitle{margin:-4px 0 12px;font-size:.9rem;line-height:1.45;color:var(--wm-muted)}"
@@ -119,6 +122,31 @@ const char CSS_STYLE[] PROGMEM = "<style>"
".wm-checkbox-row{display:flex;align-items:center;gap:8px;margin:12px 0;font-size:.92rem}"
".wm-checkbox-row label{display:inline;margin:0;font-weight:500;color:var(--wm-text)}"
".wm-form-actions{margin-top:16px}"
".wm-wifi-details-card{position:relative}"
".wm-wifi-save-overlay{position:fixed;inset:0;z-index:10002;display:none;align-items:center;justify-content:center;padding:18px;"
"box-sizing:border-box;background:rgba(15,23,42,.58)}"
".wm-wifi-save-panel{display:flex;align-items:center;gap:14px;width:100%;max-width:340px;padding:18px;border:1px solid var(--wm-border);"
"border-radius:var(--wm-radius-sm);background:var(--wm-surface);box-shadow:var(--wm-shadow);text-align:left}"
".wm-wifi-save-panel strong{display:block;font-size:1rem;color:var(--wm-text)}"
".wm-wifi-save-panel p{margin:4px 0 0;font-size:.9rem;line-height:1.45;color:var(--wm-muted)}"
".wm-operation-overlay{position:absolute;inset:0;z-index:5;display:none;align-items:center;justify-content:center;padding:16px;background:rgba(15,23,42,.56)}"
".wm-operation-panel{display:flex;align-items:center;gap:12px;width:100%;max-width:310px;padding:16px;border:1px solid var(--wm-border);border-radius:var(--wm-radius-sm);background:var(--wm-surface);box-shadow:var(--wm-shadow);text-align:left}"
".wm-operation-panel strong{display:block;font-size:.98rem;color:var(--wm-text)}"
".wm-operation-panel p{margin:3px 0 0;font-size:.88rem;line-height:1.4;color:var(--wm-muted)}"
".wm-operation-progress{margin-top:10px}"
".wm-operation-progress-track{height:7px;overflow:hidden;border-radius:999px;background:var(--wm-border)}"
".wm-operation-progress-track span{display:block;width:0;height:100%;border-radius:inherit;background:var(--wm-brand);transition:width .15s ease}"
".wm-operation-progress-label{display:block;margin-top:4px;font-size:.8rem;font-weight:700;color:var(--wm-muted);text-align:right}"
".wm-portal-timeout-row{display:flex;align-items:stretch;gap:8px;margin:0 0 12px}"
".wm-portal-timeout-row .wm-status{flex:1;margin:0}"
".wm-icon-button{display:inline-flex;align-items:center;justify-content:center;width:42px;min-height:42px;padding:0;border:1px solid var(--wm-border);border-radius:var(--wm-radius-sm);background:var(--wm-bg);color:var(--wm-text);cursor:pointer}"
".wm-icon-button:hover{border-color:var(--wm-brand);color:var(--wm-brand)}"
".wm-icon-button:disabled{opacity:.5;cursor:not-allowed}"
".wm-icon-button .wm-icon{width:1.05rem;height:1.05rem}"
".wm-inline-link{display:inline-block;margin-top:12px;font-size:.92rem}"
".wm-spinner{width:24px;height:24px;flex:0 0 24px;border:3px solid var(--wm-border);border-top-color:var(--wm-brand);border-radius:50%;animation:wm-spin .8s linear infinite}"
"@keyframes wm-spin{to{transform:rotate(360deg)}}"
"@media (prefers-reduced-motion:reduce){.wm-spinner{animation:none;border-right-color:var(--wm-brand)}}"
/* Buttons */
".wm-btn{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 18px;"
"font-size:1rem;font-weight:600;font-family:inherit;border-radius:var(--wm-radius-sm);border:0;cursor:pointer;"
@@ -128,16 +156,16 @@ const char CSS_STYLE[] PROGMEM = "<style>"
"a.wm-btn{box-sizing:border-box;text-decoration:none}"
".wm-btn .wm-icon-label{justify-content:center}"
".wm-btn--block{width:100%}"
".wm-btn--primary{background:var(--wm-brand);color:#fff}"
".wm-btn--primary{background:var(--wm-brand);color:var(--wm-brand-text)}"
".wm-btn--primary:hover{background:var(--wm-brand-hover)}"
".wm-btn--secondary{background:var(--wm-bg);color:var(--wm-text);border:1px solid var(--wm-border)}"
".wm-btn--secondary:hover{border-color:var(--wm-brand);color:var(--wm-brand)}"
".wm-btn--danger{background:var(--wm-danger);color:#fff}"
".wm-btn--danger{background:var(--wm-danger);color:var(--wm-brand-text)}"
".wm-btn--danger:hover{background:var(--wm-danger-hover)}"
"input[type=file]{width:100%;padding:10px;border:1px dashed var(--wm-border);border-radius:var(--wm-radius-sm);"
"background:var(--wm-bg);font-size:.9rem}"
/* Legacy: unclassed submit in forms */
"button[type=submit],.wm-form-actions .wm-btn--primary{background:var(--wm-brand);color:#fff}"
"button[type=submit],.wm-form-actions .wm-btn--primary{background:var(--wm-brand);color:var(--wm-brand-text)}"
"button[type=submit]:hover{background:var(--wm-brand-hover)}"
/* Device actions / button stack */
".wm-actions-stack{display:flex;flex-direction:column;gap:10px;margin-top:4px}"
@@ -179,8 +207,6 @@ const char CSS_STYLE[] PROGMEM = "<style>"
".wm-kv dd:last-of-type{border-bottom:0}"
/* Feedback areas */
"#wm-wifi-msg,#wm-param-msg{font-size:.9rem;margin-top:12px;min-height:1.2em;color:var(--wm-muted)}"
"#wm-ota-log{font-size:.8rem;background:var(--wm-bg);padding:12px;border-radius:var(--wm-radius-sm);"
"border:1px solid var(--wm-border);overflow:auto;max-height:160px}"
// invert legacy body
"body.invert{background-color:var(--wm-bg)}"
"body.invert .msg{border-color:var(--wm-border)}"
@@ -198,7 +224,7 @@ const char CSS_STYLE[] PROGMEM = "<style>"
".wm-dialog-msg{margin:0 0 16px;line-height:1.45;color:var(--wm-text)}"
".wm-dialog-actions{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap}"
".wm-dialog-actions .wm-btn{min-width:108px}"
".wm-dialog-ok,.wm-btn.wm-dialog-ok{background:var(--wm-brand)!important;color:#fff!important}"
".wm-dialog-ok,.wm-btn.wm-dialog-ok{background:var(--wm-brand)!important;color:var(--wm-brand-text)!important}"
".wm-dialog-ok:hover{background:var(--wm-brand-hover)!important}"
"body.invert .wm-toast.ok{background:rgba(22,163,74,.2)}"
"body.invert .wm-toast.err{background:rgba(220,38,38,.2)}"
+425 -120
View File
@@ -66,8 +66,13 @@ function bindPortalChromeEvents(){
}
function bindFormSubmitHandlers(){
var wf=$('wm-wifi-form'), pf=$('wm-param-form'), of=$('wm-ota-form');
if(wf && window.portalWifiSave) wf.addEventListener('submit', window.portalWifiSave);
var wf=document.querySelectorAll('.wm-wifi-data-form'), pf=$('wm-param-form'), of=$('wm-ota-form'), wa=document.querySelectorAll('[data-wm-wifi-action]'), i;
if(window.portalWifiSave){
for(i=0;i<wf.length;i++)wf[i].addEventListener('submit', window.portalWifiSave);
}
if(window.portalWifiAction){
for(i=0;i<wa.length;i++)wa[i].addEventListener('click', window.portalWifiAction);
}
if(pf && window.portalParamSave) pf.addEventListener('submit', window.portalParamSave);
if(of && window.portalOtaSubmit) of.addEventListener('submit', window.portalOtaSubmit);
}
@@ -115,7 +120,7 @@ function startPortalTimeoutCountdown(initialSeconds){
function render(){
if(!el)return;
el.className='wm-status'+(levelClass(remaining)?' '+levelClass(remaining):'');
el.textContent='Captive portal timeout: '+formatDurationSeconds(remaining);
el.textContent='Portal closes in '+formatDurationSeconds(remaining);
}
render();
_wmPortalTimeoutTimer=setInterval(function(){
@@ -255,6 +260,28 @@ function rowsWithoutKeys(rows,blocked){
return out;
}
function portalHeader(){
var brand=boot.brand||{};
var ctx=boot.context||{};
var identity=esc(ctx.identityText||brand.title||'WiFiManager');
var logoAlt=esc(brand.logoAltText||'');
var h="<header class='wm-site-header'>";
if(brand.logoSvg){h+="<div class='wm-site-logo'"+(logoAlt?" role='img' aria-label='"+logoAlt+"'":"")+">"+brand.logoSvg+"</div>";}
h+="<div class='wm-site-brand-copy'><p class='wm-site-brand-name'>"+identity+"</p>";
if(brand.tagline){h+="<p class='wm-site-tagline'>"+esc(brand.tagline)+"</p>";}
h+="</div></header>";
return h;
}
function applyPortalFavicon(){
var brand=boot.brand||{};
if(!brand.logoSvg)return;
var icon=document.querySelector("link[rel~='icon']");
if(!icon){icon=document.createElement('link');icon.rel='icon';document.head.appendChild(icon);}
icon.type='image/svg+xml';
icon.href='data:image/svg+xml,'+encodeURIComponent(brand.logoSvg);
}
function navBar(active){
active=active||'home';
var pages=boot.pages||{};
@@ -264,11 +291,11 @@ function navBar(active){
var ps=pages.setup||{};
var pi=pages.info||{};
var pu=pages.update||{};
var h="<nav class='wm-nav' aria-label='Configuration'>";
h+="<a class='wm-nav-link"+(active==='home'?' wm-nav-link--active':'')+"' href='#/'>"+labelWithIcon('home','Home')+"</a>";
h+="<a class='wm-nav-link"+(active==='wifi'?' wm-nav-link--active':'')+"' href='#/wifi'>"+labelWithIcon('wifi','WiFi')+"</a>";
if(!pinWifi && ps.visible!==false){h+="<a class='wm-nav-link"+(active==='setup'?' wm-nav-link--active':'')+"' href='#/setup'>"+labelWithIcon('setup','Setup')+"</a>";}
if(pi.visible!==false){h+="<a class='wm-nav-link"+(active==='info'?' wm-nav-link--active':'')+"' href='#/info'>"+labelWithIcon('info','Info')+"</a>";}
var h=portalHeader()+"<nav class='wm-nav' aria-label='Configuration'>";
h+="<a class='wm-nav-link"+(active==='home'?' wm-nav-link--active':'')+"' href='#/'>"+labelWithIcon('home','Overview')+"</a>";
h+="<a class='wm-nav-link"+(active==='wifi'?' wm-nav-link--active':'')+"' href='#/wifi'>"+labelWithIcon('wifi','Wi-Fi')+"</a>";
if(!pinWifi && ps.visible!==false){h+="<a class='wm-nav-link"+(active==='setup'?' wm-nav-link--active':'')+"' href='#/setup'>"+labelWithIcon('setup','Settings')+"</a>";}
if(pi.visible!==false){h+="<a class='wm-nav-link"+(active==='info'?' wm-nav-link--active':'')+"' href='#/info'>"+labelWithIcon('info','Device')+"</a>";}
if(pu.visible){h+="<a class='wm-nav-link"+(active==='update'?' wm-nav-link--active':'')+"' href='#/update'>"+labelWithIcon('update','Update')+"</a>";}
if(actions.back&&actions.back.visible){h+="<a href='#' class='wm-nav-link' id='wm-nav-back'>"+labelWithIcon('back','Back')+"</a>";}
h+="</nav>";
@@ -306,29 +333,91 @@ function renderExtraHomeCards(cards){
return out;
}
function renderHomeStatusDetails(info){
var st=info.status||{};
var rows=[];
if(st.connected!==undefined)rows.push({label:'Wi-Fi',value:st.connected?'Connected':'Not connected'});
if(st.ssid)rows.push({label:'Network',value:st.ssid});
if(st.connected&&st.stationIp&&st.stationIp!=='0.0.0.0'&&st.stationIp!=='(IP unset)')rows.push({label:'Address',value:st.stationIp});
else if(st.apIp)rows.push({label:'Portal',value:st.apIp});
var device=info.device||[];
var wanted={uptime:true,freeheap:true};
for(var i=0;i<device.length;i++){
if(device[i]&&wanted[device[i].key]){
rows.push({label:device[i].label||device[i].key,value:device[i].value||''});
}
}
if(!rows.length)return"<p class='wm-lead'>Device details are unavailable.</p>";
var html="<dl class='wm-kv wm-home-status-details'>";
for(var j=0;j<rows.length;j++){
html+="<dt>"+esc(rows[j].label)+"</dt>";
html+="<dd>"+esc(rows[j].value)+"</dd>";
}
return html+="</dl>";
}
function loadHomeStatus(){
var box=$('wm-home-status-details');
if(!box)return;
api('/api/info').then(function(res){
var info={};
try{info=JSON.parse(res.body);}catch(e){}
box.innerHTML=renderHomeStatusDetails(info);
}).catch(function(){
box.innerHTML="<p class='wm-lead'>Unable to load device details.</p>";
});
}
function resetPortalTimeout(){
var button=$('wm-reset-portal-timeout');
if(button)button.disabled=true;
api('/api/portal/timeout-reset',{method:'POST'}).then(function(res){
var data={};
try{data=JSON.parse(res.body);}catch(e){}
if(!res.ok){
showToast(data.message||'Unable to reset portal timeout.',true);
return;
}
startPortalTimeoutCountdown(data.timeoutSecondsRemaining||0);
showToast('Portal timeout reset.',false);
}).catch(function(){
showToast('Unable to reset portal timeout.',true);
}).then(function(){
if(button)button.disabled=false;
});
}
function viewHome(){
var brand=boot.brand||{};
var ctx=boot.context||{};
var t=esc(brand.title||'WiFiManager');
var ident=ctx.identityText?esc(ctx.identityText):'';
var title=esc(brand.title||'WiFiManager');
var summary=ctx.statusSummary||'';
var timeoutSeconds=ctx.portalTimeoutSecondsRemaining||0;
var html=navBar('home');
html+="<header class='wm-hero'>";
if(brand.logoSvg){html+="<div class='wm-brand-logo'>"+brand.logoSvg+"</div>";}
html+="<h1 class='wm-hero-title'>"+t+"</h1>";
if(ident){html+="<p class='wm-hero-sub'>"+ident+"</p>";}
if(brand.homeIntro){html+="<p class='wm-hero-intro'>"+esc(brand.homeIntro)+"</p>";}
html+="</header>";
html+="<div class='wm-card'>"+cardTitleHtml('status','Status');
if(summary){html+="<p class='wm-home-summary'>"+esc(summary)+"</p>";}
if(timeoutSeconds>0){html+="<p class='wm-status' id='wm-portal-timeout'></p>";}
html+="<p class='wm-lead'>Use <strong>WiFi</strong> to scan networks and connect. Open <strong>Info</strong> for addresses and firmware actions.</p>";
html+="</div>";
html+="<div class='wm-page-head'><h1>"+title+"</h1></div>";
html+="<div class='wm-card'>"+cardTitleHtml('status','Device status');
if(summary){
html+="<p class='wm-home-summary'>"+esc(summary)+"</p>";
}
if(timeoutSeconds>0){
html+="<div class='wm-portal-timeout-row'>";
html+="<p class='wm-status' id='wm-portal-timeout'></p>";
html+="<button type='button' class='wm-icon-button'";
html+=" id='wm-reset-portal-timeout' aria-label='Reset portal timeout'";
html+=" title='Reset portal timeout'>"+iconSvg('restart')+"</button></div>";
}
html+="<div id='wm-home-status-details'>";
html+="<p class='wm-lead'>Loading device details…</p></div>";
html+="<a class='wm-inline-link' href='#/info'>View full device details</a></div>";
html+=renderExtraHomeCards(boot.extraHomeCards||[]);
html+=deviceActionsHtml();
setView(html);
if(timeoutSeconds>0)startPortalTimeoutCountdown(timeoutSeconds);
if(timeoutSeconds>0){
startPortalTimeoutCountdown(timeoutSeconds);
var reset=$('wm-reset-portal-timeout');
if(reset)reset.addEventListener('click',resetPortalTimeout);
}
loadHomeStatus();
}
// --- HTTP ---
@@ -360,27 +449,55 @@ function stopWifiScanPolling(){
setScanRefreshEnabled(true);
}
function setWifiScanProgress(visible,title,detail){
var overlay=$('wm-wifi-scan-overlay');
var titleEl=$('wm-wifi-scan-title');
var detailEl=$('wm-wifi-scan-detail');
if(!overlay)return;
if(titleEl)titleEl.textContent=title||'Scanning Wi-Fi';
if(detailEl)detailEl.textContent=detail||'';
overlay.style.display=visible?'flex':'none';
overlay.setAttribute('aria-hidden',visible?'false':'true');
}
function startWifiScanPolling(box){
stopWifiScanPolling();
setScanRefreshEnabled(false);
setWifiScanProgress(true,'Scanning Wi-Fi','Looking for nearby networks…');
var token=++_wmWifiScanPollToken;
var unavailableDeadline=Date.now()+20000;
function retryAfterTemporaryRadioLoss(){
if(Date.now()<unavailableDeadline){
setWifiScanProgress(true,'Scanning Wi-Fi','Waiting for the device radio to return…');
_wmWifiScanPollTimer=setTimeout(poll,1000);
return true;
}
return false;
}
function poll(){
api('/api/wifi/scan-status').then(function(res){
if(token!==_wmWifiScanPollToken)return;
var d={};
try{d=JSON.parse(res.body);}catch(e){return;}
if(!res.ok)throw new Error('scan status unavailable');
var data={};
try{data=JSON.parse(res.body);}catch(e){
if(retryAfterTemporaryRadioLoss())return;
throw e;
}
box=getScanResultsBox()||box;
if(box)box.innerHTML=renderScanList(d);
if(!d.scanning){
if(!data.scanning){
if(box)box.innerHTML=renderScanList(data);
setWifiScanProgress(false);
stopWifiScanPolling();
return;
}
_wmWifiScanPollTimer=setTimeout(poll,800);
}).catch(function(){
if(token!==_wmWifiScanPollToken)return;
if(retryAfterTemporaryRadioLoss())return;
stopWifiScanPolling();
setWifiScanProgress(false);
box=getScanResultsBox()||box;
if(box)box.innerHTML=renderScanError('Portal became unreachable while scanning.');
if(box)box.innerHTML=renderScanError('The portal did not return after scanning.');
});
}
poll();
@@ -434,21 +551,23 @@ function wifiRefresh(){
var box=getScanResultsBox();
if(!box)return;
setScanRefreshEnabled(false);
box.innerHTML="<div class='wm-scan-list'><p class='wm-lead' style='margin:0;padding:14px 12px'>Starting scan…</p></div>";
setWifiScanProgress(true,'Preparing Wi-Fi scan','Starting a scan…');
api('/api/wifi/scan',{method:'POST'}).then(function(res){
box=getScanResultsBox()||box;
if(!res.ok){
setScanRefreshEnabled(true);
if(box)box.innerHTML=renderScanError('Failed to start WiFi scan.');
showToast('Failed to start WiFi scan.',true);
setWifiScanProgress(false);
if(box)box.innerHTML=renderScanError('Failed to start Wi-Fi scan.');
showToast('Failed to start Wi-Fi scan.',true);
return;
}
startWifiScanPolling(box);
}).catch(function(){
stopWifiScanPolling();
setWifiScanProgress(false);
box=getScanResultsBox()||box;
if(box)box.innerHTML=renderScanError('Failed to reach portal while starting scan.');
showToast('Failed to reach portal while starting scan.',true);
if(box)box.innerHTML=renderScanError('Failed to reach the portal while starting a scan.');
showToast('Failed to reach the portal while starting a scan.',true);
});
}
@@ -465,6 +584,14 @@ function bindWifiViewEvents(){
if(p)p.type=this.checked?'text':'password';
});
}
var profileShowPass=document.querySelectorAll('.wm-showpass');
var profileIndex;
for(profileIndex=0;profileIndex<profileShowPass.length;profileIndex++){
profileShowPass[profileIndex].addEventListener('change',function(){
var target=$(this.getAttribute('data-target')||'');
if(target)target.type=this.checked?'text':'password';
});
}
var scanResults=$('wm-scan-results');
if(scanResults){
@@ -473,8 +600,8 @@ function bindWifiViewEvents(){
while(el && el !== scanResults){
if(el.tagName==='A' && el.getAttribute('data-ssid')){
ev.preventDefault();
var inp=$('wm-s');
var pass=$('wm-p');
var inp=$('wm-s0')||$('wm-s');
var pass=$('wm-p0')||$('wm-p');
if(inp)inp.value=el.getAttribute('data-ssid')||'';
if(pass){
if(pass.scrollIntoView)pass.scrollIntoView({behavior:'smooth',block:'center'});
@@ -491,48 +618,101 @@ function bindWifiViewEvents(){
}
}
function viewWifi(){
setView(navBar('wifi')+"<div class='wm-card'><p class='wm-lead'>Loading WiFi options…</p></div>");
api('/api/wifi/meta').then(function(res){
try{var m=JSON.parse(res.body);}catch(e){m={};}
var html=navBar('wifi');
html+="<div class='wm-page-head'><h1>WiFi</h1><p class='wm-page-desc'>Pick a network below, confirm SSID and password, then connect.</p></div>";
html+="<div class='wm-card'>"+cardTitleHtml('scan','Networks nearby');
html+="<div id='wm-scan-results'></div>";
html+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block' id='wm-refresh-scan'>"+labelWithIcon('scan','Scan again')+"</button></div>";
html+="<div class='wm-card'>"+cardTitleHtml('wifi','Network details');
html+="<form id='wm-wifi-form'>";
html+=renderFieldList(m.wifiFields||[]);
var wf=m.wifiFields||[];
var hasPass=false;
var i;
for(i=0;i<wf.length;i++){if(wf[i]&&wf[i].id==='p'){hasPass=true;break;}}
if(hasPass){
html+="<div class='wm-checkbox-row'><input type='checkbox' id='wm-showpass'/> <label for='wm-showpass'>Show password</label></div>";
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+=renderFieldList(m.staticFields||[]);
html+=renderFieldList(m.params||[]);
html+="<div class='wm-form-actions'><button type='submit' class='wm-btn wm-btn--primary wm-btn--block'>"+labelWithIcon('connect','Save and connect')+"</button></div></form>";
html+="<div id='wm-wifi-msg'></div></div>";
html+='</form>';
}
return html;
}
function viewWifi(){
setView(navBar('wifi')+"<div class='wm-card'><p class='wm-lead'>Loading Wi-Fi options…</p></div>");
api('/api/wifi/meta').then(function(res){
var meta={};
try{meta=JSON.parse(res.body);}catch(e){}
var html=navBar('wifi');
html+="<div class='wm-page-head'><h1>Connect to Wi-Fi</h1>";
html+="<p class='wm-page-desc'>Choose a network, then save and connect.</p></div>";
html+="<div class='wm-card wm-scan-card'>"+cardTitleHtml('scan','Networks nearby');
html+="<div id='wm-scan-results' class='wm-scan-results'>";
html+="<p class='wm-lead'>Preparing nearby networks…</p></div>";
html+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block'";
html+=" id='wm-refresh-scan'>"+labelWithIcon('scan','Scan again')+"</button>";
html+="<div id='wm-wifi-scan-overlay' class='wm-operation-overlay'";
html+=" role='status' aria-live='polite' aria-atomic='true' aria-hidden='true'>";
html+="<div class='wm-operation-panel'><span class='wm-spinner' aria-hidden='true'></span>";
html+="<div><strong id='wm-wifi-scan-title'>Scanning Wi-Fi</strong>";
html+="<p id='wm-wifi-scan-detail'>Looking for nearby networks…</p></div></div></div></div>";
html+="<div class='wm-card wm-wifi-details-card'>"+cardTitleHtml('wifi','Network details');
if(Array.isArray(meta.profiles)&&meta.profiles.length){
html+=renderStationProfileFields(meta.profiles);
html+="<form class='wm-wifi-data-form' id='wm-wifi-form'>";
}else{
html+="<form class='wm-wifi-data-form' id='wm-wifi-form'>";
html+=renderFieldList(meta.wifiFields||[]);
var wifiFields=meta.wifiFields||[];
var hasPassword=false;
for(var i=0;i<wifiFields.length;i++){
if(wifiFields[i]&&wifiFields[i].id==='p'){hasPassword=true;break;}
}
if(hasPassword){
html+="<div class='wm-checkbox-row'><input type='checkbox' id='wm-showpass'/>";
html+=" <label for='wm-showpass'>Show password</label></div>";
}
}
html+=renderFieldList(meta.staticFields||[]);
html+=renderFieldList(meta.params||[]);
html+="</form>";
html+="<div class='wm-form-actions'><button type='button'";
html+=" class='wm-btn wm-btn--primary wm-btn--block' data-wm-wifi-action='connect'>";
html+=labelWithIcon('connect','Save and connect')+"</button>";
if(Array.isArray(meta.profiles)&&meta.profiles.length){
html+="<button type='button' class='wm-btn wm-btn--secondary wm-btn--block'";
html+=" data-wm-wifi-action='save'>Save for later</button>";
}
html+="</div><div id='wm-wifi-msg'></div></div>";
html+="<div id='wm-wifi-save-overlay' class='wm-wifi-save-overlay'";
html+=" role='status' aria-live='polite' aria-atomic='true' aria-hidden='true'>";
html+="<div class='wm-wifi-save-panel'><span class='wm-spinner' aria-hidden='true'></span>";
html+="<div><strong id='wm-wifi-save-title'>Saving settings</strong>";
html+="<p id='wm-wifi-save-detail'>Please wait…</p></div></div></div>";
setView(html);
bindWifiViewEvents();
setScanRefreshEnabled(true);
api('/api/wifi/scan-status').then(function(r2){
try{var d=JSON.parse(r2.body);}catch(e){d={};}
api('/api/wifi/scan-status').then(function(scanRes){
var scan={};
try{scan=JSON.parse(scanRes.body);}catch(e){}
var box=getScanResultsBox();
if(shouldAutoStartWifiScan(d)){
if(shouldAutoStartWifiScan(scan)){
wifiRefresh();
return;
}
if(box)box.innerHTML=renderScanList(d);
if(d&&d.scanning){startWifiScanPolling(box);}
if(box)box.innerHTML=renderScanList(scan);
if(scan&&scan.scanning)startWifiScanPolling(box);
}).catch(function(){
var box=getScanResultsBox();
if(box)box.innerHTML=renderScanError('Failed to reach portal while loading scan status.');
if(box)box.innerHTML=renderScanError('Failed to load nearby networks.');
setScanRefreshEnabled(true);
});
}).catch(function(){
setView(navBar('wifi')+"<div class='wm-card'><p class='wm-lead'>Failed to load WiFi options. Reconnect to the portal and try again.</p></div>");
setView(navBar('wifi')+"<div class='wm-card'><p class='wm-lead'>Failed to load Wi-Fi options. Reconnect to the portal and try again.</p></div>");
});
}
@@ -540,6 +720,33 @@ function viewWifi(){
var _wmWifiConnectPollTimer=null;
var _wmWifiConnectPollToken=0;
var _wmWifiConnectPending=false;
var _wmWifiSaveInFlight=false;
function setWifiSaveControlsDisabled(disabled){
var controls=document.querySelectorAll('.wm-wifi-data-form input,.wm-wifi-data-form select,.wm-wifi-data-form textarea,[data-wm-wifi-action],#wm-refresh-scan');
for(var i=0;i<controls.length;i++)controls[i].disabled=disabled;
}
function setWifiSaveProgress(visible,title,detail){
var overlay=$('wm-wifi-save-overlay'),titleEl=$('wm-wifi-save-title'),detailEl=$('wm-wifi-save-detail');
if(!overlay)return;
if(titleEl)titleEl.textContent=title||'Saving settings';
if(detailEl)detailEl.textContent=detail||'';
overlay.style.display=visible?'flex':'none';
overlay.setAttribute('aria-hidden',visible?'false':'true');
}
function beginWifiSave(title,detail){
_wmWifiSaveInFlight=true;
setWifiSaveControlsDisabled(true);
setWifiSaveProgress(true,title,detail);
}
function finishWifiSave(){
_wmWifiSaveInFlight=false;
setWifiSaveControlsDisabled(false);
setWifiSaveProgress(false);
}
function stopWifiConnectPolling(){
_wmWifiConnectPollToken++;
@@ -549,56 +756,91 @@ function stopWifiConnectPolling(){
}
}
function completeWifiHandoff(data,attempt){
attempt=attempt||0;
api('/api/wifi/connect-complete',{method:'POST'}).then(function(res){
if(!res.ok){
if(attempt<2){setTimeout(function(){completeWifiHandoff(data,attempt+1);},500);return;}
throw new Error('handoff acknowledgement rejected');
}
if(data.redirectUrl){
setTimeout(function(){window.location.href=data.redirectUrl;},1200);
}else{
location.hash='#/';
}
}).catch(function(){
if(attempt<2){
setTimeout(function(){completeWifiHandoff(data,attempt+1);},500);
return;
}
setWifiSaveProgress(true,'Wi-Fi connected','Open the device address shown below.');
showToast('Wi-Fi connected, but automatic redirect was unavailable.',true);
});
}
function pollWifiConnectStatus(){
stopWifiConnectPolling();
var token=++_wmWifiConnectPollToken;
function poll(){
api('/api/wifi/connect-status').then(function(res){
if(token!==_wmWifiConnectPollToken)return;
var j={};
var data={};
var msg=$('wm-wifi-msg');
try{j=JSON.parse(res.body);}catch(e){}
if(msg&&j.message){
msg.innerHTML=esc(j.message);
}
if(j.state==='success'){
try{data=JSON.parse(res.body);}catch(e){}
if(msg&&data.message)msg.innerHTML=esc(data.message);
if(data.state==='success'){
stopWifiConnectPolling();
_wmWifiConnectPending=false;
if(msg && j.stationIp){
msg.innerHTML="Connected to WiFi. Redirecting to <a href='"+esc(j.redirectUrl||("http://"+j.stationIp+"/"))+"'>"+esc(j.stationIp)+"</a>…";
if(!data.stationIp){
finishWifiSave();
if(msg)msg.innerHTML=esc(data.message||'Settings saved.');
showToast(data.message||'Settings saved.',false);
return;
}
showToast(j.message||'WiFi connected',false);
if(j.redirectUrl){
setTimeout(function(){ window.location.href=j.redirectUrl; }, 1400);
}else{
location.hash='#/';
var destination=data.redirectUrl||'';
setWifiSaveProgress(true,'Connected to Wi-Fi','Opening '+data.stationIp+'…');
if(msg){
msg.innerHTML="Connected. Opening <a href='"+esc(destination||('http://'+data.stationIp+'/'))+"'>";
msg+=esc(data.stationIp)+"</a>…";
}
}else if(j.state==='failed'){
showToast(data.message||'Wi-Fi connected',false);
completeWifiHandoff(data);
}else if(data.state==='failed'){
stopWifiConnectPolling();
_wmWifiConnectPending=false;
showToast(j.message||'WiFi connect failed',true);
finishWifiSave();
showToast(data.message||'Wi-Fi connection failed',true);
}else{
setWifiSaveProgress(true,'Connecting to Wi-Fi',data.message||'Waiting for the device to join the selected network…');
_wmWifiConnectPollTimer=setTimeout(poll,700);
}
}).catch(function(){
if(token!==_wmWifiConnectPollToken)return;
stopWifiConnectPolling();
finishWifiSave();
var msg=$('wm-wifi-msg');
if(msg){
msg.innerHTML=_wmWifiConnectPending
? 'Portal became unreachable after saving. If the device joined your WiFi network, reconnect to it there.'
: 'Portal became unreachable while checking connection status.';
}
if(msg)msg.innerHTML='Unable to check Wi-Fi connection status. Try again.';
_wmWifiConnectPending=false;
showToast('Unable to check Wi-Fi connection status.',true);
});
}
poll();
}
window.portalWifiSave=function(ev){
ev.preventDefault();
var fd=new FormData(document.getElementById('wm-wifi-form'));
function collectWifiFormData(){
var fd=new FormData(), forms=document.querySelectorAll('.wm-wifi-data-form'), i;
for(i=0;i<forms.length;i++){
new FormData(forms[i]).forEach(function(value,key){fd.append(key,value);});
}
return fd;
}
function submitWifi(action){
if(_wmWifiSaveInFlight)return false;
var fd=collectWifiFormData();
if(action)fd.append('stationAction',action);
var msg=$('wm-wifi-msg');
beginWifiSave(action==='save'?'Saving settings':'Saving and connecting',action==='save'?'Saving these settings for a later connection...':'Saving settings and preparing the WiFi connection...');
if(msg)msg.innerHTML='Submitting...';
api('/api/wifi/save',{method:'POST',body:fd}).then(function(res){
var j={};
@@ -608,10 +850,31 @@ window.portalWifiSave=function(ev){
}
if(res.status===202){
_wmWifiConnectPending=true;
setWifiSaveProgress(true,'Connecting to WiFi',(j&&j.message)?j.message:'Waiting for the device to join the selected network...');
pollWifiConnectStatus();
}else if(res.ok){
finishWifiSave();
showToast((j&&j.message)?j.message:'Settings saved.',false);
}else{
finishWifiSave();
showToast((j&&j.message)?j.message:'Unable to save settings.',true);
}
}).catch(function(){
finishWifiSave();
if(msg)msg.innerHTML='Unable to reach the portal while saving. Please try again.';
showToast('Unable to reach the portal while saving.',true);
});
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 ---
@@ -620,7 +883,7 @@ function viewInfo(){
api('/api/info').then(function(res){
try{var d=JSON.parse(res.body);}catch(e){d={};}
var html=navBar('info');
html+="<div class='wm-page-head'><h1>Device info</h1><p class='wm-page-desc'>Connection status, hardware, and actions.</p></div>";
html+="<div class='wm-page-head'><h1>Device</h1><p class='wm-page-desc'>Connection status, hardware, and actions.</p></div>";
var st=d.status;
if(st && typeof st==='object' && !Array.isArray(st)){
html+="<div class='wm-card'>"+cardTitleHtml('status','Status');
@@ -689,7 +952,7 @@ function viewSetup(){
api('/api/params').then(function(res){
try{var d=JSON.parse(res.body);}catch(e){d={};}
var html=navBar('setup');
html+="<div class='wm-page-head'><h1>Setup</h1><p class='wm-page-desc'>Custom parameters stored on this device.</p></div>";
html+="<div class='wm-page-head'><h1>Settings</h1><p class='wm-page-desc'>Custom parameters stored on this device.</p></div>";
html+="<div class='wm-card'><form id='wm-param-form'>";
html+=renderFieldList(d.params||[]);
html+="<div class='wm-form-actions'><button type='submit' class='wm-btn wm-btn--primary wm-btn--block'>"+labelWithIcon('save','Save parameters')+"</button></div>";
@@ -710,59 +973,100 @@ window.portalParamSave=function(ev){
return false;
};
function setOtaControlsDisabled(disabled){
var controls=document.querySelectorAll('#wm-ota-form input,#wm-ota-form button');
for(var i=0;i<controls.length;i++)controls[i].disabled=disabled;
}
function setOtaProgress(visible,title,detail,percent){
var overlay=$('wm-ota-overlay');
var titleEl=$('wm-ota-title');
var detailEl=$('wm-ota-detail');
var progress=$('wm-ota-progress');
var bar=$('wm-ota-progress-bar');
var label=$('wm-ota-progress-label');
if(!overlay)return;
if(titleEl)titleEl.textContent=title||'Uploading firmware';
if(detailEl)detailEl.textContent=detail||'';
var hasPercent=typeof percent==='number';
if(progress)progress.style.display=hasPercent?'block':'none';
if(hasPercent){
var value=Math.max(0,Math.min(100,Math.round(percent)));
if(bar)bar.style.width=value+'%';
if(label)label.textContent=value+'%';
}
overlay.style.display=visible?'flex':'none';
overlay.setAttribute('aria-hidden',visible?'false':'true');
}
function viewUpdate(){
var pages=boot.pages||{};
var pupd=pages.update||{};
var update=pages.update||{};
var html=navBar('update');
html+="<div class='wm-page-head'><h1>Firmware update</h1><p class='wm-page-desc'>Flash a new firmware build (.bin). The device reboots when done.</p></div>";
if(!pupd.visible){
html+="<div class='wm-card'><p class='wm-lead'>Firmware update is disabled for this configuration.</p></div>";
html+="<div class='wm-page-head'><h1>Update firmware</h1>";
html+="<p class='wm-page-desc'>Upload a firmware build for this device.</p></div>";
if(!update.visible){
html+="<div class='wm-card'><p class='wm-lead'>Firmware update is disabled for this device.</p></div>";
setView(html);
return;
}
html+="<div class='wm-card'><form id='wm-ota-form'>";
html+="<div class='wm-card wm-ota-card'><form id='wm-ota-form'>";
html+="<div class='wm-field'><label for='wm-ota-file'>Firmware file</label>";
html+="<input type='file' id='wm-ota-file' name='update' accept='.bin,.bin.gz'/></div>";
html+="<div class='wm-form-actions'><button type='submit' class='wm-btn wm-btn--primary wm-btn--block'>"+labelWithIcon('upload','Upload firmware')+"</button></div>";
html+="</form>";
html+="<p class='wm-lead' style='margin-top:14px;margin-bottom:8px'>Choose the correct binary for this chip. Upload progress appears below.</p>";
html+="<pre id='wm-ota-log'></pre></div>";
html+="<div class='wm-form-actions'><button type='submit'";
html+=" class='wm-btn wm-btn--primary wm-btn--block'>";
html+=labelWithIcon('upload','Upload firmware')+"</button></div></form>";
html+="<p class='wm-lead'>Use the binary built for this device. It will restart after a successful update.</p>";
html+="<div id='wm-ota-overlay' class='wm-operation-overlay'";
html+=" role='status' aria-live='polite' aria-atomic='true' aria-hidden='true'>";
html+="<div class='wm-operation-panel'><span class='wm-spinner' aria-hidden='true'></span><div>";
html+="<strong id='wm-ota-title'>Uploading firmware</strong>";
html+="<p id='wm-ota-detail'>Preparing upload…</p>";
html+="<div id='wm-ota-progress' class='wm-operation-progress' style='display:none'>";
html+="<div class='wm-operation-progress-track'><span id='wm-ota-progress-bar'></span></div>";
html+="<span id='wm-ota-progress-label'>0%</span></div></div></div></div></div>";
setView(html);
}
window.portalOtaSubmit=function(ev){
ev.preventDefault();
var fi=$('wm-ota-file');
var logEl=$('wm-ota-log');
if(!fi||!fi.files||!fi.files[0]){
if(logEl)logEl.textContent='Choose a firmware file first.';
var fileInput=$('wm-ota-file');
if(!fileInput||!fileInput.files||!fileInput.files[0]){
showToast('Choose a firmware file first.',true);
return false;
}
if(logEl)logEl.textContent='Uploading…';
setOtaControlsDisabled(true);
setOtaProgress(true,'Uploading firmware','Preparing upload…');
var xhr=new XMLHttpRequest();
xhr.upload.onprogress=function(e){
if(!logEl||!e.lengthComputable)return;
logEl.textContent='Uploading… '+Math.round(100*e.loaded/e.total)+'%';
xhr.upload.onprogress=function(event){
if(!event.lengthComputable)return;
var percent=100*event.loaded/event.total;
setOtaProgress(true,'Uploading firmware','Transferring firmware…',percent);
};
xhr.onreadystatechange=function(){
if(xhr.readyState!==4)return;
var t=xhr.responseText||'';
if(logEl)logEl.textContent=t;
var httpOk=xhr.status>=200&&xhr.status<300;
try{
var j=JSON.parse(t);
if(j&&j.message){
if(logEl)logEl.textContent=j.message;
showToast(j.message,j.ok===false);
return;
}
}catch(e){}
showToast(httpOk?'Upload finished.':('HTTP '+xhr.status),!httpOk);
var text=xhr.responseText||'';
var data={};
try{data=JSON.parse(text);}catch(e){}
var ok=xhr.status>=200&&xhr.status<300&&data.ok!==false;
if(ok){
setOtaProgress(true,'Firmware updated','Restarting device…',100);
showToast(data.message||'Firmware updated. Restarting device…',false);
return;
}
setOtaProgress(false);
setOtaControlsDisabled(false);
showToast(data.message||('Upload failed (HTTP '+xhr.status+').'),true);
};
xhr.onerror=function(){
setOtaProgress(false);
setOtaControlsDisabled(false);
showToast('Upload failed before the device responded.',true);
};
xhr.open('POST','/u');
var fd=new FormData();
fd.append('update',fi.files[0]);
xhr.send(fd);
var formData=new FormData();
formData.append('update',fileInput.files[0]);
xhr.send(formData);
return false;
};
@@ -787,6 +1091,7 @@ function route(){
}
function bootPortal(){
applyPortalFavicon();
route();
try{document.dispatchEvent(new CustomEvent('wm:ready',{detail:{boot:boot}}));}catch(e){}
}
@@ -7,7 +7,7 @@
* Single-page portal shell: only HTML document served for GET /.
* Dynamic UI is driven by embedded JS + JSON APIs under /api/...
*
* Placeholder keys (%PAGE_TITLE%, %STYLES%, %BOOTSTRAP_JSON%, %PORTAL_APP_JS%, %PORTAL_APPEND_JS%) are filled
* Placeholder keys (%PAGE_TITLE%, %STYLES%, %PORTAL_THEME%, %BOOTSTRAP_JSON%, %PORTAL_APP_JS%) are filled
* per request in WiFiManagerHandlers::handleRoot; do not treat placeholders as a customization API.
*/
@@ -25,6 +25,7 @@ const char WM_ROOT_SHELL_TEMPLATE[] PROGMEM =
"<meta name='viewport' content='width=device-width,initial-scale=1,user-scalable=no'/>"
"<title>%PAGE_TITLE%</title>"
"%STYLES%"
"%PORTAL_THEME%"
"</head>"
"<body class='portal'>"
"<div id='wm-toast' class='wm-toast' aria-live='polite' role='status' style='display:none'></div>"
@@ -41,9 +42,6 @@ const char WM_ROOT_SHELL_TEMPLATE[] PROGMEM =
"<script>"
"%PORTAL_APP_JS%"
"</script>"
"<script>"
"%PORTAL_APPEND_JS%"
"</script>"
"</body>"
"</html>";
+589 -33
View File
@@ -27,6 +27,11 @@ uint8_t WiFiManager::_lastconxresulttmp = WL_IDLE_STATUS;
namespace {
constexpr size_t kMaxHostnameLength = 32;
constexpr uint8_t kSoftApStartMaxAttempts = 3;
constexpr unsigned long kStationProfileSwitchDelayMs = 2000UL; // ESP WiFi needs time to leave a failed association.
// A portal client acknowledges the successful station hand-off before the AP
// is closed. The fallback protects headless/captive clients that disappear.
constexpr unsigned long kPortalSuccessHandoffFallbackMs = 12000UL;
constexpr unsigned long kPortalSuccessHandoffAcknowledgedDelayMs = 700UL;
bool isValidHostnameChar(char c) {
return isAlphaNumeric(c) || c == '-';
@@ -55,6 +60,25 @@ bool normalizeHostname(String& hostname) {
return true;
}
const char kDefaultPortalTitle[] PROGMEM = "WiFiManager";
bool isSafePortalText(const WiFiManagerPortalText& value) {
for (size_t i = 0; i < value.length(); ++i) {
const char c = value.at(i);
if (!(isAlphaNumeric(c) || c == '#' || c == '(' || c == ')' || c == ',' ||
c == '.' || c == '%' || c == ' ' || c == '-' || c == '/')) {
return false;
}
}
return true;
}
void appendPortalText(String& destination, const WiFiManagerPortalText& value) {
for (size_t i = 0; i < value.length(); ++i) {
destination += value.at(i);
}
}
} // namespace
/**
@@ -148,6 +172,7 @@ WiFiManager::WiFiManager() {
}
void WiFiManager::WiFiManagerInit(){
_portalBrand.title = WiFiManagerPortalText::progmem(kDefaultPortalTitle);
if (_logEnabled && (uint8_t)_runtimeMaxLevel >= (uint8_t)WiFiManagerLogLevel::Trace) {
debugPlatformInfo();
}
@@ -197,6 +222,496 @@ void WiFiManager::_end(){
if(_userpersistent) WiFi.persistent(true); // reenable persistent, there is no getter we rely on _userpersistent
}
void WiFiManager::setStationProfileStore(WiFiManagerStationProfileStore* store) {
_stationProfileStore = store;
_stationProfilesLoaded = false;
_stationCandidateActive = false;
_stationCandidateFromPortal = false;
_stationAttemptMask = 0;
_stationPendingSlot = WM_NO_STATION_PROFILE;
_stationNextAttemptAt = 0;
_stationStatus = wm_station_status_t();
}
bool WiFiManager::isStationProfileMode() const {
return _stationProfileStore != nullptr;
}
void WiFiManager::setStationRecoveryInterval(unsigned long intervalMs) {
if (intervalMs > 0) {
_stationRecoveryInterval = intervalMs;
}
}
const WiFiManagerStationProfiles& WiFiManager::getStationProfiles() const {
return _stationProfiles;
}
const WiFiManager::wm_station_status_t& WiFiManager::getStationStatus() const {
return _stationStatus;
}
bool WiFiManager::isStationProfileEnabled(const WiFiManagerStationProfiles& profiles, uint8_t slot) const {
return slot < WM_STATION_PROFILE_COUNT && profiles.slots[slot].enabled && profiles.slots[slot].ssid[0] != '\0';
}
uint8_t WiFiManager::configuredStationProfileCount(const WiFiManagerStationProfiles& profiles) const {
uint8_t count = 0;
for (uint8_t slot = 0; slot < WM_STATION_PROFILE_COUNT; ++slot) {
if (isStationProfileEnabled(profiles, slot)) {
++count;
}
}
return count;
}
bool WiFiManager::validateStationProfiles(const WiFiManagerStationProfiles& profiles) const {
if (profiles.preferredSlot >= WM_STATION_PROFILE_COUNT || !isStationProfileEnabled(profiles, 0)) {
return false;
}
for (uint8_t slot = 0; slot < WM_STATION_PROFILE_COUNT; ++slot) {
const WiFiManagerStationProfile& profile = profiles.slots[slot];
if (!profile.enabled) {
continue;
}
if (memchr(profile.ssid, '\0', sizeof(profile.ssid)) == nullptr ||
memchr(profile.password, '\0', sizeof(profile.password)) == nullptr) {
return false;
}
}
return profiles.lastSuccessfulSlot == WM_NO_STATION_PROFILE ||
isStationProfileEnabled(profiles, profiles.lastSuccessfulSlot);
}
uint8_t WiFiManager::chooseStationProfile(const WiFiManagerStationProfiles& profiles,
bool preferLastSuccessful) const {
if (preferLastSuccessful && isStationProfileEnabled(profiles, profiles.lastSuccessfulSlot)) {
return profiles.lastSuccessfulSlot;
}
if (isStationProfileEnabled(profiles, profiles.preferredSlot)) {
return profiles.preferredSlot;
}
for (uint8_t slot = 0; slot < WM_STATION_PROFILE_COUNT; ++slot) {
if (isStationProfileEnabled(profiles, slot)) {
return slot;
}
}
return WM_NO_STATION_PROFILE;
}
const WiFiManagerStationProfiles& WiFiManager::stationProfilesForAttempt() const {
return _stationCandidateActive ? _stationCandidate : _stationProfiles;
}
WiFiManagerStationProfiles& WiFiManager::stationProfilesForAttempt() {
return _stationCandidateActive ? _stationCandidate : _stationProfiles;
}
unsigned long WiFiManager::stationAttemptTimeout() const {
// The synchronous legacy connection path allows an unlimited timeout. A
// profile controller must stay bounded so it can advance to the fallback.
return _connectTimeout > 0 ? _connectTimeout : 15000UL;
}
bool WiFiManager::hasUsableStationConnection() const {
if (WiFi.status() != WL_CONNECTED) {
return false;
}
return WiFi.localIP() != IPAddress(0, 0, 0, 0);
}
bool WiFiManager::startStationConnection(char const *apName, char const *apPassword) {
if (!isStationProfileMode()) {
return false;
}
_begin();
_stationPortalApName = apName ? apName : getDefaultAPName();
_stationPortalApPassword = apPassword ? apPassword : "";
_stationCandidateActive = false;
_stationCandidateFromPortal = false;
_stationAttemptMask = 0;
_stationPendingSlot = WM_NO_STATION_PROFILE;
_stationNextAttemptAt = 0;
_stationStatus = wm_station_status_t();
_stationStatus.state = WM_STATION_LOADING;
_stationStatus.message = F("Loading WiFi profiles");
WiFiManagerStationProfiles loaded;
if (_stationProfileStore->load(loaded)) {
_stationProfiles = loaded;
} else {
_stationProfiles = WiFiManagerStationProfiles();
}
_stationProfilesLoaded = true;
_stationStatus.configuredProfiles = configuredStationProfileCount(_stationProfiles);
if (!validateStationProfiles(_stationProfiles)) {
// In station-profile mode the caller store is the single source of
// truth. ESP32 can reconnect from credentials retained in its own NVS
// before the portal is started, which would otherwise bypass an empty or
// invalid external profile set. Clear only that SDK-owned state here;
// the external store remains untouched and is what the portal will save.
WiFi_Disconnect();
WiFi_eraseConfig();
_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) {
// Profile-backed provisioning reaches this path rather than
// processPortalConnect(). Keep the AP available until the SPA has read
// the station address and acknowledged its redirect hand-off.
if (_disableConfigPortal && (configPortalActive || webPortalActive)) {
_cpConnectCloseAt = millis() + kPortalSuccessHandoffFallbackMs;
_cpConnectState = wm_cp_connect_state_t::success_waiting_close;
return;
}
if (_savewificallback != NULL) {
_savewificallback();
}
emitPortalEvent(WM_EVENT_PORTAL_CONNECT_SUCCESS);
if (_disableConfigPortal) {
shutdownConfigPortal();
}
} else {
updateConxResult(status);
emitPortalEvent(WM_EVENT_PORTAL_CONNECT_FAILED);
}
}
void WiFiManager::acknowledgePortalConnectHandoff() {
if (_cpConnectState != wm_cp_connect_state_t::success_waiting_close) {
return;
}
_cpConnectCloseAt = millis() + kPortalSuccessHandoffAcknowledgedDelayMs;
}
#ifdef UNIT_TEST
void WiFiManager::wmTestCompleteProfilePortalConnectionSuccess() {
_stationCandidateFromPortal = true;
configPortalActive = true;
_disableConfigPortal = true;
completePortalStationAttempt(true, WL_CONNECTED, F("WiFi connected"));
}
#endif
void WiFiManager::handleStationConnectionSuccess() {
const uint8_t slot = _stationStatus.attemptedSlot;
const bool wasCandidate = _stationCandidateActive;
const bool wasPortalCandidate = _stationCandidateFromPortal;
bool persisted = true;
if (wasCandidate) {
const WiFiManagerStationProfiles previous = _stationProfiles;
_stationCandidate.lastSuccessfulSlot = slot;
if (_stationProfileStore) {
persisted = _stationProfileStore->save(_stationCandidate);
}
if (persisted) {
_stationProfiles = _stationCandidate;
_stationProfilesLoaded = true;
} else {
_stationProfiles = previous;
}
_stationCandidateActive = false;
_stationCandidateFromPortal = false;
} else {
const bool changed = _stationProfiles.lastSuccessfulSlot != slot;
_stationProfiles.lastSuccessfulSlot = slot;
if (changed && _stationProfileStore) {
persisted = _stationProfileStore->save(_stationProfiles);
}
}
_stationEverConnected = true;
_stationStatus.state = WM_STATION_CONNECTED;
_stationStatus.activeSlot = slot;
_stationStatus.wifiStatus = WL_CONNECTED;
_stationStatus.lastConnectionWasCandidate = wasCandidate;
_stationStatus.storageSaveFailed = !persisted;
_stationStatus.message = persisted ? F("WiFi connected") : F("WiFi connected but profiles could not be saved");
emitPortalEvent(WM_EVENT_STATION_PROFILE_CONNECTED);
if (wasPortalCandidate) {
completePortalStationAttempt(persisted, persisted ? WL_CONNECTED : WL_CONNECT_FAILED,
_stationStatus.message);
}
}
void WiFiManager::enterStationPortal() {
_stationStatus.state = WM_STATION_PORTAL;
if (_stationStatus.message.length() == 0) {
_stationStatus.message = F("WiFi configuration required");
}
if (configPortalActive || !_enableConfigPortal) {
return;
}
String apName = _stationPortalApName.length() ? _stationPortalApName : getDefaultAPName();
startConfigPortal(apName.c_str(), _stationPortalApPassword.length() ? _stationPortalApPassword.c_str() : NULL);
}
void WiFiManager::handleStationAttemptFailure(uint8_t status, const String& message) {
_stationStatus.wifiStatus = status;
_stationStatus.message = message;
const WiFiManagerStationProfiles& profiles = stationProfilesForAttempt();
for (uint8_t slot = 0; slot < WM_STATION_PROFILE_COUNT; ++slot) {
const uint8_t bit = static_cast<uint8_t>(1U << slot);
if (isStationProfileEnabled(profiles, slot) && !(_stationAttemptMask & bit)) {
queueStationProfile(slot);
return;
}
}
const bool wasCandidate = _stationCandidateActive;
const bool wasPortalCandidate = _stationCandidateFromPortal;
if (wasCandidate) {
_stationCandidateActive = false;
_stationCandidateFromPortal = false;
_stationAttemptMask = 0;
_stationPendingSlot = WM_NO_STATION_PROFILE;
_stationNextAttemptAt = 0;
if (wasPortalCandidate) {
_stationStatus.state = WM_STATION_PORTAL;
completePortalStationAttempt(false, status, message);
emitPortalEvent(WM_EVENT_STATION_PROFILE_FAILED);
return;
}
if (validateStationProfiles(_stationProfiles)) {
_stationStatus.configuredProfiles = configuredStationProfileCount(_stationProfiles);
beginStationCycle(true);
return;
}
}
emitPortalEvent(WM_EVENT_STATION_PROFILE_FAILED);
if (_stationEverConnected) {
_stationStatus.state = WM_STATION_BACKOFF;
_stationBackoffStartedAt = millis();
_stationStatus.message = F("WiFi profiles unavailable; retrying shortly");
emitPortalEvent(WM_EVENT_STATION_BACKOFF);
} else {
enterStationPortal();
}
}
void WiFiManager::processStationController() {
if (!isStationProfileMode() && !_stationCandidateActive) {
return;
}
switch (_stationStatus.state) {
case WM_STATION_ATTEMPTING: {
const uint8_t status = WiFi.status();
_stationStatus.wifiStatus = status;
if (hasUsableStationConnection()) {
handleStationConnectionSuccess();
return;
}
if ((millis() - _stationAttemptStartedAt) >= stationAttemptTimeout()) {
handleStationAttemptFailure(WL_CONNECT_FAILED, F("WiFi connection timed out"));
}
return;
}
case WM_STATION_SWITCHING:
if (static_cast<long>(millis() - _stationNextAttemptAt) >= 0) {
const uint8_t nextSlot = _stationPendingSlot;
_stationPendingSlot = WM_NO_STATION_PROFILE;
_stationNextAttemptAt = 0;
if (nextSlot == WM_NO_STATION_PROFILE) {
handleStationAttemptFailure(WL_CONNECT_FAILED, F("WiFi profile switch lost its target"));
} else {
beginStationProfile(nextSlot);
}
}
return;
case WM_STATION_CONNECTED:
if (!hasUsableStationConnection()) {
emitPortalEvent(WM_EVENT_STATION_LINK_LOST);
_stationAttemptMask = 0;
_stationPendingSlot = WM_NO_STATION_PROFILE;
_stationNextAttemptAt = 0;
const uint8_t active = isStationProfileEnabled(_stationProfiles, _stationStatus.activeSlot)
? _stationStatus.activeSlot
: chooseStationProfile(_stationProfiles, true);
if (active == WM_NO_STATION_PROFILE) {
enterStationPortal();
} else {
beginStationProfile(active);
}
}
return;
case WM_STATION_BACKOFF:
if ((millis() - _stationBackoffStartedAt) >= _stationRecoveryInterval) {
beginStationCycle(true);
}
return;
default:
return;
}
}
boolean WiFiManager::autoConnect() {
String ssid = getDefaultAPName();
return autoConnect(ssid.c_str(), NULL);
@@ -388,16 +903,6 @@ bool WiFiManager::startAP(){
log(WiFiManagerLogLevel::Info, kWiFiMgrLogSubsystem, F("StartAP with SSID: "),_apName);
#endif
#ifdef ESP8266
if(!WiFi.enableAP(true)) {
#ifndef WM_NO_LOG
log(WiFiManagerLogLevel::Error, kWiFiMgrLogSubsystem,F("[ERROR] enableAP failed!"));
#endif
return false;
}
delay(500); // workaround delay
#endif
// setup optional soft AP static ip config
if (_ap_static_ip) {
#ifndef WM_NO_LOG
@@ -656,7 +1161,10 @@ boolean WiFiManager::process(){
#endif
processScan();
processPortalConnect();
processStationController();
if (!_stationCandidateFromPortal) {
processPortalConnect();
}
if(webPortalActive || configPortalActive){
// if timed out or abort, break
@@ -704,7 +1212,6 @@ void WiFiManager::queuePortalConnect(const String& ssid, const String& pass) {
void WiFiManager::processPortalConnect() {
const unsigned long now = millis();
const unsigned long successHandoffDelayMs = 2500UL;
auto finalizePortalConnectSuccess = [&]() {
_cpConnectState = wm_cp_connect_state_t::success;
if (_savewificallback != NULL) {
@@ -806,14 +1313,15 @@ void WiFiManager::processPortalConnect() {
? String(F("WiFi connected. Redirecting to ")) + _cpConnectStationIp
: String(F("WiFi connected"));
if (_disableConfigPortal && (configPortalActive || webPortalActive)) {
_cpConnectCloseAt = now + successHandoffDelayMs;
_cpConnectCloseAt = now + kPortalSuccessHandoffFallbackMs;
_cpConnectState = wm_cp_connect_state_t::success_waiting_close;
} else {
finalizePortalConnectSuccess();
}
return;
}
if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL || status == WL_CONNECTION_LOST) {
if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL ||
status == WL_CONNECTION_LOST) {
failPortalConnect(status, getWLStatusString(status));
return;
}
@@ -2095,20 +2603,78 @@ void WiFiManager::setWiFiAPHidden(bool hidden){
}
void WiFiManager::portalSetBrandTitle(const String& title) {
_portalBrand.title = title;
bool WiFiManager::canChangePortalPresentation() const {
return !configPortalActive && !webPortalActive;
}
void WiFiManager::portalSetContextIdentityText(const String& identityText) {
_portalBrand.identityTextOverride = identityText;
bool WiFiManager::isPortalThemeValid(const WiFiManagerPortalTheme& theme) const {
return isSafePortalText(theme.pageBackground) && isSafePortalText(theme.surface) &&
isSafePortalText(theme.text) && isSafePortalText(theme.mutedText) &&
isSafePortalText(theme.border) && isSafePortalText(theme.accent) &&
isSafePortalText(theme.accentHover) && isSafePortalText(theme.accentText) &&
isSafePortalText(theme.danger) && isSafePortalText(theme.dangerHover) &&
isSafePortalText(theme.success) && theme.cornerRadiusPx <= 64 &&
theme.smallCornerRadiusPx <= 64;
}
void WiFiManager::portalSetBrandHomeIntro(const String& text) {
_portalBrand.homeIntro = text;
void WiFiManager::rebuildPortalThemeStyle() {
_portalThemeStyle = "";
const bool hasColours = !_portalTheme.pageBackground.empty() || !_portalTheme.surface.empty() ||
!_portalTheme.text.empty() || !_portalTheme.mutedText.empty() || !_portalTheme.border.empty() ||
!_portalTheme.accent.empty() || !_portalTheme.accentHover.empty() ||
!_portalTheme.accentText.empty() || !_portalTheme.danger.empty() ||
!_portalTheme.dangerHover.empty() || !_portalTheme.success.empty();
if (!hasColours && _portalTheme.cornerRadiusPx == 0 && _portalTheme.smallCornerRadiusPx == 0) {
return;
}
const auto appendToken = [this](const char* name, const WiFiManagerPortalText& value) {
if (value.empty()) return;
_portalThemeStyle += name;
_portalThemeStyle += ':';
appendPortalText(_portalThemeStyle, value);
_portalThemeStyle += ';';
};
_portalThemeStyle = F("<style id='wm-portal-theme'>:root{");
appendToken("--wm-bg", _portalTheme.pageBackground);
appendToken("--wm-surface", _portalTheme.surface);
appendToken("--wm-text", _portalTheme.text);
appendToken("--wm-muted", _portalTheme.mutedText);
appendToken("--wm-border", _portalTheme.border);
appendToken("--wm-brand", _portalTheme.accent);
appendToken("--wm-brand-hover", _portalTheme.accentHover);
appendToken("--wm-brand-text", _portalTheme.accentText);
appendToken("--wm-danger", _portalTheme.danger);
appendToken("--wm-danger-hover", _portalTheme.dangerHover);
appendToken("--wm-success", _portalTheme.success);
if (_portalTheme.cornerRadiusPx > 0) {
_portalThemeStyle += F("--wm-radius:");
_portalThemeStyle += String(_portalTheme.cornerRadiusPx);
_portalThemeStyle += F("px;");
}
if (_portalTheme.smallCornerRadiusPx > 0) {
_portalThemeStyle += F("--wm-radius-sm:");
_portalThemeStyle += String(_portalTheme.smallCornerRadiusPx);
_portalThemeStyle += F("px;");
}
_portalThemeStyle += F("}</style>");
}
void WiFiManager::portalSetBrandLogoSvg(const String& svgMarkup) {
_portalBrand.logoSvg = svgMarkup;
bool WiFiManager::setPortalConfig(const WiFiManagerPortalConfig& config) {
if (!canChangePortalPresentation() || !isPortalThemeValid(config.theme)) {
return false;
}
_portalBrand.title = config.title.empty()
? WiFiManagerPortalText::progmem(kDefaultPortalTitle)
: config.title;
_portalBrand.identityTextOverride = config.identityText;
_portalBrand.tagline = config.tagline;
_portalBrand.logo = config.logo;
_portalBrand.logoAltText = config.logoAltText;
_portalTheme = config.theme;
rebuildPortalThemeStyle();
return true;
}
void WiFiManager::portalSetPageInfoVisible(bool visible) {
@@ -2123,6 +2689,7 @@ void WiFiManager::portalSetPageSetupVisible(bool visible) {
_portalPages.setupVisible = visible;
}
void WiFiManager::portalSetActionEraseVisible(bool visible) {
_portalActions.eraseVisible = visible;
}
@@ -2227,17 +2794,6 @@ void WiFiManager::portalClearHomeCards() {
_portalStructured.homeCards.clear();
}
void WiFiManager::portalAppendCss(const String& css) {
_portalAssets.appendedCss += css;
}
void WiFiManager::portalOverrideCss(const String& css) {
_portalAssets.overriddenCss = css;
}
void WiFiManager::portalAppendJs(const String& js) {
_portalAssets.appendedJs += js;
}
/**
* check if the config portal is running
+213 -70
View File
@@ -37,8 +37,25 @@ static void jsonAppendEscaped(String& out, const String& s) {
}
}
static void jsonAppendEscaped(String& out, const WiFiManagerPortalText& text) {
for (size_t i = 0; i < text.length(); ++i) {
const char c = text.at(i);
if (c == '"') {
out += F("\\\"");
} else if (c == '\\') {
out += F("\\\\");
} else if (c == '\n') {
out += F("\\n");
} else if (c != '\r') {
out += c;
}
}
}
namespace {
const char kEmptyPortalPlaceholder[] PROGMEM = "";
inline void reservePage(String& page, size_t extraBytes = WM_PAGE_RESERVE_BYTES) {
if (extraBytes == 0) return;
const size_t targetLen = page.length() + extraBytes;
@@ -60,24 +77,26 @@ void configureDynamicStringDescriptor(DynamicDataDescriptor& descriptor, String&
descriptor.userData = &value;
}
String readProgmemString(const char* p) {
return String(FPSTR(p));
const char* portalTextGetter(void* userData) {
const auto* text = static_cast<const WiFiManagerPortalText*>(userData);
return text && text->storage == WiFiManagerPortalStorage::Ram && text->data
? text->data
: "";
}
String stripStyleWrapper(const String& styleBlock) {
String css = styleBlock;
const String openTag = F("<style>");
const String closeTag = F("</style>");
if (css.startsWith(openTag)) {
css.remove(0, openTag.length());
}
if (css.endsWith(closeTag)) {
css.remove(css.length() - closeTag.length());
}
return css;
size_t portalTextLengthGetter(const char* /*data*/, void* userData) {
const auto* text = static_cast<const WiFiManagerPortalText*>(userData);
return text ? text->length() : 0;
}
void configurePortalTextDescriptor(DynamicDataDescriptor& descriptor,
const WiFiManagerPortalText& text) {
descriptor.getter = &portalTextGetter;
descriptor.getLength = &portalTextLengthGetter;
descriptor.userData = const_cast<WiFiManagerPortalText*>(&text);
}
const char* portalHomeCardKindJson(PortalHomeCardKind k) {
switch (k) {
case PortalHomeCardKind::Text:
@@ -110,31 +129,27 @@ AsyncWebServerResponse* beginTemplateResponse(AsyncWebServerRequest* request,
);
}
// Shell contract (must match templates/RootShell.h — customize UI via WiFiManager portal* APIs + bootstrap JSON, not registries):
// Shell contract (must match templates/RootShell.h):
// %PAGE_TITLE% -> document title (from WiFiManager title state)
// %STYLES% -> portal CSS (embedded default + portalAppendCss / portalOverrideCss)
// %STYLES% -> immutable built-in portal stylesheet in PROGMEM
// %PORTAL_THEME% -> optional validated semantic-token override
// %BOOTSTRAP_JSON% -> initial SPA runtime payload
// %PORTAL_APP_JS% -> embedded SPA source
// %PORTAL_APPEND_JS% -> consumer hook script (portalAppendJs)
struct PortalShellRenderBundle {
String bootstrapJson;
String pageTitleStatic;
String stylesStatic;
String appendJsStatic;
WiFiManagerPortalText pageTitle;
PlaceholderRegistry registry;
TemplateContext context;
DynamicDataDescriptor bootstrapDescriptor;
DynamicDataDescriptor pageTitleDescriptor;
DynamicDataDescriptor stylesDescriptor;
DynamicDataDescriptor appendJsDescriptor;
DynamicDataDescriptor themeDescriptor;
PortalShellRenderBundle()
: registry(WM_TEMPLATE_REGISTRY_CAPACITY),
bootstrapDescriptor{},
pageTitleDescriptor{},
stylesDescriptor{},
appendJsDescriptor{} {}
themeDescriptor{} {}
};
// Custom HTML attribute strings (e.g. from DeviceFramework generateCustomHTML) may include
@@ -150,24 +165,6 @@ bool portalCustomAttrsIndicatePassword(const String& customAttrs) {
WiFiManagerHandlers::WiFiManagerHandlers(WiFiManager* wm) : _wm(wm) {}
String WiFiManagerHandlers::composePortalStylesheet() const {
String css;
if (_wm != nullptr && _wm->_portalAssets.overriddenCss.length() > 0) {
css = _wm->_portalAssets.overriddenCss;
} else {
css = stripStyleWrapper(readProgmemString(CSS_STYLE));
}
if (_wm != nullptr && _wm->_portalAssets.appendedCss.length() > 0) {
css += _wm->_portalAssets.appendedCss;
}
String out = F("<style>");
out += css;
out += F("</style>");
return out;
}
void WiFiManagerHandlers::appendPortalExtraInfoSectionsJson(String& json, bool& first) {
if (_wm == nullptr) {
return;
@@ -199,7 +196,6 @@ void WiFiManagerHandlers::appendPortalExtraInfoSectionsJson(String& json, bool&
json += F("]}");
}
}
void WiFiManagerHandlers::appendPortalExtraHomeCardsJson(String& json, bool& first) {
if (_wm == nullptr) {
return;
@@ -686,25 +682,24 @@ void WiFiManagerHandlers::handleRoot(AsyncWebServerRequest *request) {
// - WiFiManagerHandlers owns request-time shell assembly; WiFiManagerServer owns HTTP lifecycle only.
// - Build one request-scoped PortalShellRenderBundle.
// - Populate one request-scoped PlaceholderRegistry with shell defaults + request payloads.
// - Render WM_ROOT_SHELL_TEMPLATE.
// - Render WM_ROOT_SHELL_TEMPLATE with static CSS plus an optional small theme block.
auto bundle = std::make_shared<PortalShellRenderBundle>();
const bool hasCustomStyles = _wm != nullptr &&
(_wm->_portalAssets.overriddenCss.length() > 0 || _wm->_portalAssets.appendedCss.length() > 0);
bundle->pageTitleStatic = _wm ? _wm->_portalBrand.title : String();
bundle->pageTitle = _wm ? _wm->_portalBrand.title : WiFiManagerPortalText{};
bundle->bootstrapJson = buildPortalBootstrapJson();
bundle->appendJsStatic = _wm ? _wm->_portalAssets.appendedJs : String();
bundle->registry.registerProgmemData("%STYLES%", CSS_STYLE);
bundle->registry.registerProgmemData("%PORTAL_APP_JS%", PORTAL_APP_JS);
if (hasCustomStyles) {
bundle->stylesStatic = composePortalStylesheet();
configureDynamicStringDescriptor(bundle->stylesDescriptor, bundle->stylesStatic);
bundle->registry.registerDynamicData("%STYLES%", &bundle->stylesDescriptor);
if (_wm) {
configureDynamicStringDescriptor(bundle->themeDescriptor, _wm->_portalThemeStyle);
bundle->registry.registerDynamicData("%PORTAL_THEME%", &bundle->themeDescriptor);
} else {
bundle->registry.registerProgmemData("%STYLES%", CSS_STYLE);
bundle->registry.registerProgmemData("%PORTAL_THEME%", kEmptyPortalPlaceholder);
}
if (bundle->pageTitle.storage == WiFiManagerPortalStorage::Progmem && bundle->pageTitle.data) {
bundle->registry.registerProgmemData("%PAGE_TITLE%", bundle->pageTitle.data);
} else {
configurePortalTextDescriptor(bundle->pageTitleDescriptor, bundle->pageTitle);
bundle->registry.registerDynamicData("%PAGE_TITLE%", &bundle->pageTitleDescriptor);
}
configureDynamicStringDescriptor(bundle->appendJsDescriptor, bundle->appendJsStatic);
bundle->registry.registerDynamicData("%PORTAL_APPEND_JS%", &bundle->appendJsDescriptor);
configureDynamicStringDescriptor(bundle->pageTitleDescriptor, bundle->pageTitleStatic);
bundle->registry.registerDynamicData("%PAGE_TITLE%", &bundle->pageTitleDescriptor);
configureDynamicStringDescriptor(bundle->bootstrapDescriptor, bundle->bootstrapJson);
bundle->registry.registerDynamicData("%BOOTSTRAP_JSON%", &bundle->bootstrapDescriptor);
@@ -783,6 +778,62 @@ void WiFiManagerHandlers::applyWifiAndParamsFromRequest(AsyncWebServerRequest *r
}
}
bool WiFiManagerHandlers::buildStationProfilesFromRequest(
AsyncWebServerRequest *request, WiFiManagerStationProfiles& profiles) {
profiles = _wm->_stationProfiles;
if (!_wm->validateStationProfiles(profiles)) {
profiles = WiFiManagerStationProfiles();
}
profiles.preferredSlot = 0;
for (uint8_t slot = 0; slot < WM_STATION_PROFILE_COUNT; ++slot) {
WiFiManagerStationProfile& profile = profiles.slots[slot];
const String ssidName = String(F("s")) + String(slot);
const String passwordName = String(F("p")) + String(slot);
const String clearName = String(F("clear")) + String(slot);
if (request->hasParam(ssidName.c_str(), true)) {
const String ssid = request->getParam(ssidName.c_str(), true)->value();
if (ssid.length() >= sizeof(profile.ssid)) {
return false;
}
memset(profile.ssid, 0, sizeof(profile.ssid));
memcpy(profile.ssid, ssid.c_str(), ssid.length());
profile.enabled = ssid.length() > 0;
if (!profile.enabled) {
profile.hasPassword = false;
memset(profile.password, 0, sizeof(profile.password));
if (profiles.lastSuccessfulSlot == slot) {
profiles.lastSuccessfulSlot = WM_NO_STATION_PROFILE;
}
}
}
if (request->hasParam(clearName.c_str(), true)) {
profile.hasPassword = false;
memset(profile.password, 0, sizeof(profile.password));
} else if (request->hasParam(passwordName.c_str(), true)) {
const String password = request->getParam(passwordName.c_str(), true)->value();
if (password.length() >= sizeof(profile.password)) {
return false;
}
// A blank password means "unchanged". Explicit clear is used for an
// open network so a browser never erases a stored secret by accident.
if (password.length() > 0) {
memset(profile.password, 0, sizeof(profile.password));
memcpy(profile.password, password.c_str(), password.length());
profile.hasPassword = true;
}
}
}
if (profiles.lastSuccessfulSlot != WM_NO_STATION_PROFILE &&
!_wm->isStationProfileEnabled(profiles, profiles.lastSuccessfulSlot)) {
profiles.lastSuccessfulSlot = WM_NO_STATION_PROFILE;
}
return _wm->validateStationProfiles(profiles);
}
void WiFiManagerHandlers::doParamSave(WiFiManager::WiFiManagerRequestArgs requestArgs){
if ( _wm->_presaveparamscallback != NULL) {
_wm->_presaveparamscallback();
@@ -1013,14 +1064,16 @@ String WiFiManagerHandlers::buildPortalBootstrapJson() {
const bool portalRunning = _wm->configPortalActive || _wm->webPortalActive;
String json;
reservePage(json, 1200);
json += F("{\"contractVersion\":2");
json += F("{\"contractVersion\":3");
json += F(",\"brand\":{");
json += F("\"title\":\"");
jsonAppendEscaped(json, _wm->_portalBrand.title);
json += F("\",\"homeIntro\":\"");
jsonAppendEscaped(json, _wm->_portalBrand.homeIntro);
json += F("\",\"tagline\":\"");
jsonAppendEscaped(json, _wm->_portalBrand.tagline);
json += F("\",\"logoSvg\":\"");
jsonAppendEscaped(json, _wm->_portalBrand.logoSvg);
jsonAppendEscaped(json, _wm->_portalBrand.logo.svg);
json += F("\",\"logoAltText\":\"");
jsonAppendEscaped(json, _wm->_portalBrand.logoAltText);
json += F("\"}");
json += F(",\"context\":{");
json += F("\"portalActive\":");
@@ -1038,15 +1091,12 @@ String WiFiManagerHandlers::buildPortalBootstrapJson() {
json += String(timeoutRemainingSeconds);
}
json += F(",\"identityText\":\"");
{
String idText;
if (_wm->_portalBrand.identityTextOverride.length() > 0) {
idText = _wm->_portalBrand.identityTextOverride;
} else if (_wm->configPortalActive) {
idText = _wm->_apName;
} else {
idText = _wm->getWiFiHostname() + " - " + WiFi.localIP().toString();
}
if (!_wm->_portalBrand.identityTextOverride.empty()) {
jsonAppendEscaped(json, _wm->_portalBrand.identityTextOverride);
} else {
const String idText = _wm->configPortalActive
? _wm->_apName
: _wm->getWiFiHostname() + " - " + WiFi.localIP().toString();
jsonAppendEscaped(json, idText);
}
json += F("\",\"statusSummary\":\"");
@@ -1193,6 +1243,48 @@ void WiFiManagerHandlers::handleApiWifiScan(AsyncWebServerRequest *request) {
}
String WiFiManagerHandlers::buildApiWifiMetaJson() {
if (_wm->isStationProfileMode()) {
const WiFiManagerStationProfiles& profiles = _wm->getStationProfiles();
const WiFiManager::wm_station_status_t& station = _wm->getStationStatus();
String json = F("{\"profiles\":[");
for (uint8_t slot = 0; slot < WM_STATION_PROFILE_COUNT; ++slot) {
if (slot > 0) json += ',';
const WiFiManagerStationProfile& profile = profiles.slots[slot];
json += F("{\"slot\":\"");
json += slot == 0 ? F("primary") : F("fallback");
json += F("\",\"configured\":");
json += profile.enabled ? F("true") : F("false");
json += F(",\"ssid\":\"");
jsonAppendEscaped(json, profile.enabled ? String(profile.ssid) : String());
json += F("\",\"passwordSet\":");
json += profile.hasPassword ? F("true") : F("false");
json += F("}");
}
json += F("],\"activeSlot\":");
if (station.activeSlot == WM_NO_STATION_PROFILE) json += F("null");
else json += station.activeSlot == 0 ? F("\"primary\"") : F("\"fallback\"");
json += F(",\"state\":");
switch (station.state) {
case WiFiManager::WM_STATION_ATTEMPTING: json += F("\"connecting\""); break;
case WiFiManager::WM_STATION_CONNECTED: json += F("\"connected\""); break;
case WiFiManager::WM_STATION_BACKOFF: json += F("\"backoff\""); break;
case WiFiManager::WM_STATION_PORTAL: json += F("\"portal\""); break;
default: json += F("\"idle\""); break;
}
json += F(",\"wifiFields\":[],\"staticFields\":[");
bool first = true;
appendPortalJsonStaticFields(json, first);
json += F("],\"params\":[");
first = true;
if (_wm->_portalLayout.paramsOnWifiPage && _wm->getParametersCount() > 0) {
appendPortalJsonCustomParams(json, first);
}
json += F("],\"actions\":{\"canRefreshScan\":true,\"showBack\":");
json += _wm->_portalActions.backVisible ? F("true") : F("false");
json += F("}}");
return json;
}
String ssidPlaceholder = _wm->WiFi_SSID();
String passwordPlaceholder = "";
switch (_wm->_portalPasswordPlaceholderMode) {
@@ -1240,6 +1332,30 @@ void WiFiManagerHandlers::handleApiWifiSave(AsyncWebServerRequest *request) {
#endif
handleRequest(request);
applyWifiAndParamsFromRequest(request);
if (_wm->isStationProfileMode()) {
WiFiManagerStationProfiles candidate;
if (!buildStationProfilesFromRequest(request, candidate)) {
sendApiJson(request, 400, F("{\"ok\":false,\"message\":\"Primary WiFi is required and SSID/password lengths must be valid\"}"));
return;
}
const bool saveForLater = request->hasParam("stationAction", true) &&
request->getParam("stationAction", true)->value() == F("save");
if (saveForLater) {
if (!_wm->saveStationProfiles(candidate)) {
sendApiJson(request, 500, F("{\"ok\":false,\"message\":\"WiFi profiles could not be saved\"}"));
return;
}
sendApiJson(request, 200, F("{\"ok\":true,\"message\":\"WiFi profiles saved for later\"}"));
return;
}
if (!_wm->startStationCandidate(candidate)) {
sendApiJson(request, 400, F("{\"ok\":false,\"message\":\"WiFi profile candidate was rejected\"}"));
return;
}
sendApiJson(request, 202,
F("{\"ok\":true,\"message\":\"WiFi profiles accepted\",\"next\":{\"poll\":\"/api/wifi/connect-status\"}}"));
return;
}
_wm->queuePortalConnect(_wm->_ssid, _wm->_pass);
sendApiJson(
request, 202,
@@ -1288,6 +1404,33 @@ void WiFiManagerHandlers::handleApiWifiConnectStatus(AsyncWebServerRequest *requ
sendApiJson(request, 200, buildApiWifiConnectStatusJson());
}
void WiFiManagerHandlers::handleApiWifiConnectComplete(AsyncWebServerRequest *request) {
handleRequest(request);
if (!_wm->didConfigPortalConnectSucceed() || _wm->_cpConnectStationIp.length() == 0) {
sendApiJson(request, 409, F("{\"ok\":false,\"message\":\"WiFi hand-off is not ready\"}"));
return;
}
// The SPA has received the station address and can navigate to it. Keep the
// portal alive briefly so the normal device web server can start cleanly.
sendApiJson(request, 200, F("{\"ok\":true}"));
_wm->acknowledgePortalConnectHandoff();
}
void WiFiManagerHandlers::handleApiPortalTimeoutReset(AsyncWebServerRequest *request) {
handleRequest(request);
if (!_wm->configPortalActive || _wm->_configPortalTimeout == 0) {
sendApiJson(request, 409, F("{\"ok\":false,\"message\":\"Portal timeout is not active\"}"));
return;
}
_wm->_configPortalStart = millis();
String json = F("{\"ok\":true,\"timeoutSecondsRemaining\":");
json += String((_wm->_configPortalTimeout + 999UL) / 1000UL);
json += F("}");
sendApiJson(request, 200, json);
}
String WiFiManagerHandlers::buildApiParamsGetJson() {
String json = F("{\"params\":[");
bool first = true;
@@ -96,6 +96,12 @@ void WiFiManagerServer::registerRoutes() {
server->on(WM_G(R_api_wifi_connect_status), HTTP_GET, [this](AsyncWebServerRequest *request) {
this->_handlers->handleApiWifiConnectStatus(request);
});
server->on(WM_G(R_api_wifi_connect_complete), HTTP_POST, [this](AsyncWebServerRequest *request) {
this->_handlers->handleApiWifiConnectComplete(request);
});
server->on(WM_G(R_api_portal_timeout_reset), HTTP_POST, [this](AsyncWebServerRequest *request) {
this->_handlers->handleApiPortalTimeoutReset(request);
});
server->on(WM_G(R_api_params), HTTP_GET, [this](AsyncWebServerRequest *request) {
this->_handlers->handleApiParamsGet(request);
});
+25 -5
View File
@@ -1,6 +1,6 @@
{
"name": "WiFiManager",
"version": "3.0.1",
"version": "3.2.1",
"keywords": [
"wifi",
"wi-fi",
@@ -38,10 +38,29 @@
"espressif8266",
"espressif32"
],
"dependencies": {
"ESP32Async/ESPAsyncWebServer": "3.9.1",
"DeviceFrameworkTemplateEngine": "https://github.com/alexhopeoconnor/DFTE.git#v1.0.1"
},
"dependencies": [
{
"owner": "esp32async",
"name": "ESPAsyncWebServer",
"version": "3.9.1"
},
{
"owner": "esp32async",
"name": "ESPAsyncTCP",
"version": "^2.0.0",
"platforms": "espressif8266"
},
{
"owner": "esp32async",
"name": "AsyncTCP",
"version": "^3.4.9",
"platforms": "espressif32"
},
{
"name": "DeviceFrameworkTemplateEngine",
"version": "https://github.com/alexhopeoconnor/DFTE.git#v1.2.0"
}
],
"build": {
"includeDir": "lib/WiFiManager/include",
"srcDir": "lib/WiFiManager/src"
@@ -52,6 +71,7 @@
"lib/WiFiManager/src",
"LICENSE",
"README.md",
"CHANGELOG.md",
"library.json"
]
},
+7 -3
View File
@@ -16,10 +16,11 @@ build_flags =
-DUNIT_TEST
lib_deps =
ESP32Async/ESPAsyncWebServer@3.9.1
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.0.1
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.1.0
ESP32Async/ESPAsyncTCP@2.0.0
[env:esp32]
platform = espressif32@6.13.0
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
board = esp32dev
framework = arduino
monitor_speed = 115200
@@ -29,11 +30,14 @@ build_unflags =
-std=gnu++11
build_flags =
-std=gnu++14
-DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
-DWM_LOG_LEVEL=5
-DUNIT_TEST
lib_deps =
ESP32Async/ESPAsyncWebServer@3.9.1
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.0.1
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.1.0
ESP32Async/AsyncTCP@^3.4.9
; Optional: compile tests with DFTE logs bridged into WiFiManager::log (see README)
[env:esp8266_dfte_log]
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 vMAJOR.MINOR.PATCH" >&2
exit 2
}
tag="${1:-}"
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version="${tag#v}"
repo_url="https://github.com/alexhopeoconnor/WiFiManager.git"
reference_files=(README.md docs/GETTING_STARTED.md)
current_version="$(sed -n 's/.*"version": "\([^"]*\)".*/\1/p' "$root/library.json" | head -n 1)"
[[ "$current_version" != "$version" ]] || {
echo "library.json already declares $version; choose a new version." >&2
exit 1
}
grep -q "^## $version$" "$root/CHANGELOG.md" && {
echo "CHANGELOG.md already has a $version section; choose a new version." >&2
exit 1
}
sed -i -E '0,/"version": "[0-9]+\.[0-9]+\.[0-9]+"/s//"version": "'"$version"'"/' "$root/library.json"
for file in "${reference_files[@]}"; do
sed -i -E "s|${repo_url}#v[0-9]+\.[0-9]+\.[0-9]+|${repo_url}#v${version}|g" "$root/$file"
done
temp_file="$(mktemp)"
trap 'rm -f "$temp_file"' EXIT
{
IFS= read -r changelog_heading < "$root/CHANGELOG.md"
[[ "$changelog_heading" == "# Changelog" ]] || {
echo "CHANGELOG.md must begin with # Changelog" >&2
exit 1
}
printf '%s\n\n## %s\n\n- TODO: Describe this release.\n' "$changelog_heading" "$version"
tail -n +2 "$root/CHANGELOG.md"
} > "$temp_file"
mv "$temp_file" "$root/CHANGELOG.md"
echo "Updated WiFiManager declarations and canonical install references to $tag."
echo "Replace the generated changelog TODO with the release summary, then run scripts/check-docs.sh and scripts/prepare-release.sh $tag."
+62
View File
@@ -0,0 +1,62 @@
#!/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
while IFS= read -r example; do
for required in README.md platformio.ini; do
if [[ ! -f "$example/$required" ]]; then
printf 'Incomplete example: %s is missing %s\n' "${example#$root/}" "$required" >&2
failed=1
fi
done
if ! find "$example" -maxdepth 2 -type f \( -name '*.ino' -o -name '*.cpp' \) -print -quit | grep -q .; then
printf 'Incomplete example: %s has no sketch source\n' "${example#$root/}" >&2
failed=1
fi
done < <(find "$root/examples" -mindepth 1 -maxdepth 1 -type d -print | sort)
if [[ "$failed" -ne 0 ]]; then
exit 1
fi
echo "WiFiManager documentation checks passed"
+26 -1
View File
@@ -19,6 +19,11 @@ if [[ "$manifest_version" != "$version" ]]; then
exit 1
fi
grep -q "^## $version$" "$root/CHANGELOG.md" || {
echo "CHANGELOG.md has no $version heading" >&2
exit 1
}
if [[ -f "$root/library.properties" ]]; then
properties_version="$(sed -n 's/^version=//p' "$root/library.properties" | head -n 1)"
if [[ "$properties_version" != "$version" ]]; then
@@ -27,10 +32,30 @@ if [[ -f "$root/library.properties" ]]; then
fi
fi
if awk -v heading="## $version" '
$0 == heading { found = 1; next }
found && /^## / { exit }
found { print }
' "$root/CHANGELOG.md" | grep -Fq 'TODO: Describe this release.'; then
echo "CHANGELOG.md still has the generated TODO for $version" >&2
exit 1
fi
repo_url="https://github.com/alexhopeoconnor/WiFiManager.git"
validate_reference() {
local file="$1"
local reference_count
reference_count="$(grep -F "$repo_url#v" "$root/$file" | wc -l)"
[[ "$reference_count" -eq 1 ]] || { echo "$file must contain exactly one canonical release reference" >&2; exit 1; }
grep -Fq "$repo_url#$tag" "$root/$file" || { echo "$file does not reference $tag" >&2; exit 1; }
}
validate_reference README.md
validate_reference docs/GETTING_STARTED.md
git -C "$root" diff --check
package_dir="$(mktemp -d)"
trap 'rm -rf "$package_dir"' EXIT
pio pkg pack "$root" --output "$package_dir/package.tar.gz" >/dev/null
echo "Validated PlatformIO package for $tag"
echo "Validated release metadata and PlatformIO package for $tag"
if [[ "${2:-}" == "--tag" ]]; then
git -C "$root" diff --quiet
+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"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 compile|examples --platform esp8266|esp32" >&2
exit 2
}
[[ $# -eq 3 && ( "${1:-}" == "compile" || "${1:-}" == "examples" ) && "${2:-}" == "--platform" ]] || usage
platform="${3:-}"
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ "$1" == "examples" ]]; then
mapfile -t examples < <(find "$root/examples" -mindepth 2 -maxdepth 2 -type f -name platformio.ini -printf '%h\n' | sort)
if (( ${#examples[@]} == 0 )); then
echo "No example projects found" >&2
exit 1
fi
for example in "${examples[@]}"; do
pio run -d "$example" -e "$platform" </dev/null
done
echo "WiFiManager examples compile check passed for $platform"
exit 0
fi
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"
+32
View File
@@ -0,0 +1,32 @@
[platformio]
default_envs = esp8266
[common]
framework = arduino
lib_ldf_mode = deep+
lib_deps =
WiFiManager=symlink://../..
[env:esp8266]
extends = common
platform = espressif8266
board = d1_mini
platform_packages =
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
build_flags =
-DWM_LOG_LEVEL=3
lib_deps =
${common.lib_deps}
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
-std=gnu++14
-DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
-DWM_LOG_LEVEL=3
lib_deps =
${common.lib_deps}
+46
View File
@@ -0,0 +1,46 @@
#include <Arduino.h>
#include <WiFiManager.h>
namespace {
const char kTitle[] PROGMEM = "Set up Compile Fixture";
const char kIdentity[] PROGMEM = "WiFiManager";
const char kTagline[] 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(kTagline),
WiFiManagerPortalAsset::svgFromProgmem(kLogo),
WiFiManagerPortalText::progmem(kLogoAlt),
{
WiFiManagerPortalText::progmem(kPage),
WiFiManagerPortalText::progmem(kSurface),
WiFiManagerPortalText::progmem(kText),
WiFiManagerPortalText::progmem(kMuted),
WiFiManagerPortalText::progmem(kBorder),
WiFiManagerPortalText::progmem(kAccent),
WiFiManagerPortalText::progmem(kAccentHover),
WiFiManagerPortalText::progmem(kAccentText),
{}, {}, {}, 10, 6,
},
};
} // namespace
WiFiManager wifiManager;
void setup() {
wifiManager.setPortalConfig(kPortalConfig);
wifiManager.setConfigPortalTimeout(1);
}
void loop() {}
+5 -2
View File
@@ -13,6 +13,7 @@ TestCase tests[] = {
TEST_ENTRY(test_api_wifi_meta_json_shape),
TEST_ENTRY(test_api_wifi_meta_password_field_type),
TEST_ENTRY(test_api_wifi_connect_status_success_redirect),
TEST_ENTRY(test_profile_portal_success_keeps_handoff_alive),
TEST_ENTRY(test_api_info_json_shape),
TEST_ENTRY(test_api_params_json_shape),
TEST_ENTRY(test_api_status_json_shape),
@@ -36,9 +37,11 @@ TestCase tests[] = {
TEST_ENTRY(test_config_portal_already_active),
TEST_ENTRY(test_get_config_portal_ssid),
TEST_ENTRY(test_bootstrap_json_portal_feature_flags),
TEST_ENTRY(test_bootstrap_json_contract_v2),
TEST_ENTRY(test_portal_default_presentation),
TEST_ENTRY(test_bootstrap_json_contract_v3),
TEST_ENTRY(test_bootstrap_json_snapshot_consistency),
TEST_ENTRY(test_root_render_interleaved_context_isolation),
TEST_ENTRY(test_portal_presentation_configuration),
// Non-blocking tests
TEST_ENTRY(test_nonblocking_process),
@@ -104,7 +107,7 @@ TestCase tests[] = {
// Template rendering tests
TEST_ENTRY(test_shell_template_renders_core_placeholders),
TEST_ENTRY(test_shell_template_renders_dynamic_styles_with_percent_values),
TEST_ENTRY(test_shell_template_renders_dynamic_theme_with_percent_values),
// State transition tests
TEST_ENTRY(test_portal_to_connected_transition),
+5 -2
View File
@@ -38,9 +38,11 @@ void test_config_portal_multiple_start_stop();
void test_config_portal_already_active();
void test_get_config_portal_ssid();
void test_bootstrap_json_portal_feature_flags();
void test_bootstrap_json_contract_v2();
void test_portal_default_presentation();
void test_bootstrap_json_contract_v3();
void test_bootstrap_json_snapshot_consistency();
void test_root_render_interleaved_context_isolation();
void test_portal_presentation_configuration();
// Non-blocking tests
void test_nonblocking_process();
@@ -108,13 +110,14 @@ void test_scan_generation_invalidated_on_reset();
// Template rendering tests
void test_shell_template_renders_core_placeholders();
void test_shell_template_renders_dynamic_styles_with_percent_values();
void test_shell_template_renders_dynamic_theme_with_percent_values();
// API JSON + captive helper tests
void test_captive_redirect_host_rule();
void test_api_wifi_meta_json_shape();
void test_api_wifi_meta_password_field_type();
void test_api_wifi_connect_status_success_redirect();
void test_profile_portal_success_keeps_handoff_alive();
void test_api_info_json_shape();
void test_api_params_json_shape();
void test_api_status_json_shape();
@@ -88,8 +88,10 @@ void test_ui_customization() {
WiFiManager wm;
// Minimal customization retained: title
wm.portalSetBrandTitle("MyDevice");
// Minimal customization uses the complete static portal configuration.
WiFiManagerPortalConfig portal;
portal.title = WiFiManagerPortalText::ram("MyDevice");
TEST_ASSERT_TRUE(wm.setPortalConfig(portal));
// All setters executed without crash
TEST_ASSERT_TRUE_MESSAGE(true, "UI customization options executed without crash");
@@ -78,6 +78,24 @@ void test_api_wifi_connect_status_success_redirect() {
Serial.println("[TEST] WiFi connect-status success redirect payload test completed successfully");
}
void test_profile_portal_success_keeps_handoff_alive() {
Serial.println("[TEST] Testing profile portal success hand-off delay...");
WiFiManager wm;
#ifdef UNIT_TEST
wm.wmTestCompleteProfilePortalConnectionSuccess();
TEST_ASSERT_EQUAL(WiFiManager::WM_CP_CONNECT_SUCCESS, wm.getConfigPortalConnectState());
TEST_ASSERT_TRUE_MESSAGE(
wm.isConfigPortalConnectPending(),
"Profile-backed portal success must remain pending while the client reads the redirect response"
);
#else
TEST_IGNORE_MESSAGE("UNIT_TEST helpers unavailable");
#endif
Serial.println("[TEST] Profile portal success hand-off delay test completed successfully");
}
void test_api_info_json_shape() {
Serial.println("[TEST] Testing /api/info JSON shape...");
@@ -39,8 +39,10 @@ void test_configuration_setters() {
wm.portalSetBehaviorCaptivePortalEnabled(true);
wm.portalSetBehaviorCaptivePortalEnabled(false);
// Test custom title path
wm.portalSetBrandTitle("TestTitle");
// Test the typed portal presentation path.
WiFiManagerPortalConfig portal;
portal.title = WiFiManagerPortalText::ram("TestTitle");
TEST_ASSERT_TRUE(wm.setPortalConfig(portal));
// Call setters multiple times with different values.
wm.setConfigPortalTimeout(60);
@@ -76,16 +76,29 @@ void test_bootstrap_json_portal_feature_flags() {
Serial.println("[TEST] Bootstrap portal feature flags test completed successfully");
}
void test_bootstrap_json_contract_v2() {
Serial.println("[TEST] Testing bootstrap JSON v2 contract...");
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("\"tagline\":\"\"")));
TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"logoSvg\":\"\"")));
TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"logoAltText\":\"\"")));
}
void test_bootstrap_json_contract_v3() {
Serial.println("[TEST] Testing bootstrap JSON v3 contract...");
WiFiManager wm;
WiFiManagerHandlers handlers(&wm);
wm.portalSetBrandTitle("Solar Battery Monitor Setup");
wm.portalSetContextIdentityText("Solar Battery Monitor");
wm.portalSetBrandHomeIntro("Connect this device to WiFi and finish setup.");
wm.portalSetBrandLogoSvg("<svg viewBox='0 0 24 24'></svg>");
WiFiManagerPortalConfig portal;
portal.title = WiFiManagerPortalText::ram("Solar Battery Monitor Setup");
portal.identityText = WiFiManagerPortalText::ram("Solar Battery Monitor");
portal.tagline = WiFiManagerPortalText::ram("Connect this device to WiFi and finish setup.");
portal.logo = WiFiManagerPortalAsset::svgFromRam("<svg viewBox='0 0 24 24'></svg>");
TEST_ASSERT_TRUE(wm.setPortalConfig(portal));
wm.portalSetPageInfoVisible(true);
wm.portalSetPageUpdateVisible(false);
wm.portalSetPageSetupVisible(true);
@@ -102,10 +115,10 @@ void test_bootstrap_json_contract_v2() {
wm.portalAddHomeCard(card);
String j = handlers.buildPortalBootstrapJson();
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"contractVersion\":2")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"contractVersion\":3")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"brand\":{")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"title\":\"Solar Battery Monitor Setup\"")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"homeIntro\":\"Connect this device to WiFi and finish setup.\"")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"tagline\":\"Connect this device to WiFi and finish setup.\"")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"logoSvg\":\"<svg viewBox='0 0 24 24'></svg>\"")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"context\":{")));
TEST_ASSERT_NOT_EQUAL(-1, j.indexOf(F("\"identityText\":\"Solar Battery Monitor\"")));
@@ -163,11 +176,11 @@ void test_root_render_interleaved_context_isolation() {
registryA.registerProgmemData("%STYLES%", kEmptyTemplateChunk);
registryA.registerProgmemData("%PAGE_TITLE%", kTestTitle);
registryA.registerProgmemData("%PORTAL_APPEND_JS%", kEmptyTemplateChunk);
registryA.registerProgmemData("%PORTAL_THEME%", kEmptyTemplateChunk);
registryB.registerProgmemData("%STYLES%", kEmptyTemplateChunk);
registryB.registerProgmemData("%PAGE_TITLE%", kTestTitle);
registryB.registerProgmemData("%PORTAL_APPEND_JS%", kEmptyTemplateChunk);
registryB.registerProgmemData("%PORTAL_THEME%", kEmptyTemplateChunk);
String bootstrapA = F("{\"ctx\":\"A\"}");
String appJsA = F("// shell A");
@@ -217,3 +230,51 @@ void test_root_render_interleaved_context_isolation() {
Serial.println("[TEST] Root render interleaved context isolation test completed successfully");
}
void test_portal_presentation_configuration() {
Serial.println("[TEST] Testing portal presentation configuration...");
WiFiManager wm;
WiFiManagerHandlers handlers(&wm);
WiFiManagerPortalConfig config;
config.title = WiFiManagerPortalText::ram("Set up Temperature Monitor");
config.identityText = WiFiManagerPortalText::ram("Example Devices");
config.tagline = WiFiManagerPortalText::ram("Connect this device to WiFi.");
config.logo = WiFiManagerPortalAsset::svgFromRam("<svg viewBox='0 0 24 24'></svg>");
config.logoAltText = WiFiManagerPortalText::ram("Example Devices 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\":\"Example Devices\"")));
TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"logoAltText\":\"Example Devices logo\"")));
WiFiManagerPortalConfig unsafeConfig = config;
unsafeConfig.theme.accent = WiFiManagerPortalText::ram("#347a45;body{display:none}");
TEST_ASSERT_FALSE_MESSAGE(wm.setPortalConfig(unsafeConfig),
"Semantic theme values must reject stylesheet injection characters");
wm.startWebPortal();
TEST_ASSERT_FALSE_MESSAGE(wm.setPortalConfig(config),
"Portal presentation must remain immutable while async routes are active");
wm.stopWebPortal();
TEST_ASSERT_TRUE_MESSAGE(wm.setPortalConfig(config),
"Presentation can be applied after the portal stops");
Serial.println("[TEST] Portal presentation configuration test completed successfully");
}
@@ -28,7 +28,7 @@ const char kEmpty[] PROGMEM = "";
const char kDocTitle[] PROGMEM = "Config ESP";
const char kBootstrapJson[] PROGMEM = "{\"title\":\"Test\"}";
const char kPortalAppJs[] PROGMEM = "console.log('portal');";
const char kPortalAppendJs[] PROGMEM = "";
const char kPortalTheme[] PROGMEM = "";
const char* dynamicStringGetter(void* userData) {
const auto* value = static_cast<const String*>(userData);
@@ -55,7 +55,7 @@ void test_shell_template_renders_core_placeholders() {
registry.registerProgmemData("%PAGE_TITLE%", kDocTitle);
registry.registerProgmemData("%BOOTSTRAP_JSON%", kBootstrapJson);
registry.registerProgmemData("%PORTAL_APP_JS%", kPortalAppJs);
registry.registerProgmemData("%PORTAL_APPEND_JS%", kPortalAppendJs);
registry.registerProgmemData("%PORTAL_THEME%", kPortalTheme);
String output = renderTemplate(WM_ROOT_SHELL_TEMPLATE, registry);
@@ -70,35 +70,32 @@ void test_shell_template_renders_core_placeholders() {
Serial.println("[TEST] Portal shell template rendering test completed successfully");
}
void test_shell_template_renders_dynamic_styles_with_percent_values() {
Serial.println("[TEST] Testing portal shell dynamic styles placeholder with percent values...");
void test_shell_template_renders_dynamic_theme_with_percent_values() {
Serial.println("[TEST] Testing portal shell dynamic theme placeholder with percent values...");
PlaceholderRegistry registry(8);
String styles = F("<style>body{width:100%;max-width:92%}</style>");
String theme = F("<style>:root{--wm-bg:rgb(20,50,30);--wm-width:100%}</style>");
String title = F("Config ESP");
String bootstrap = F("{\"title\":\"Test\"}");
String appendJs = F("");
DynamicDataDescriptor stylesDescriptor{};
DynamicDataDescriptor themeDescriptor{};
DynamicDataDescriptor titleDescriptor{};
DynamicDataDescriptor bootstrapDescriptor{};
DynamicDataDescriptor appendJsDescriptor{};
configureDescriptor(stylesDescriptor, styles);
configureDescriptor(themeDescriptor, theme);
configureDescriptor(titleDescriptor, title);
configureDescriptor(bootstrapDescriptor, bootstrap);
configureDescriptor(appendJsDescriptor, appendJs);
TEST_ASSERT_TRUE(registry.registerDynamicData("%STYLES%", &stylesDescriptor));
TEST_ASSERT_TRUE(registry.registerProgmemData("%STYLES%", kEmpty));
TEST_ASSERT_TRUE(registry.registerDynamicData("%PORTAL_THEME%", &themeDescriptor));
TEST_ASSERT_TRUE(registry.registerDynamicData("%PAGE_TITLE%", &titleDescriptor));
TEST_ASSERT_TRUE(registry.registerDynamicData("%BOOTSTRAP_JSON%", &bootstrapDescriptor));
TEST_ASSERT_TRUE(registry.registerProgmemData("%PORTAL_APP_JS%", kPortalAppJs));
TEST_ASSERT_TRUE(registry.registerDynamicData("%PORTAL_APPEND_JS%", &appendJsDescriptor));
String output = renderTemplate(WM_ROOT_SHELL_TEMPLATE, registry);
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("<style>body{width:100%;max-width:92%}</style>"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("<style>:root{--wm-bg:rgb(20,50,30);--wm-width:100%}</style>"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("<title>Config ESP</title>"));
TEST_ASSERT_NOT_EQUAL(-1, output.indexOf("{\"title\":\"Test\"}"));
Serial.println("[TEST] Dynamic styles placeholder percent rendering test completed successfully");
Serial.println("[TEST] Dynamic theme placeholder percent rendering test completed successfully");
}