Compare commits

...
17 Commits
85 changed files with 4961 additions and 636 deletions
+18 -1
View File
@@ -2,12 +2,28 @@ 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 tools/portal-hardware tools/lib/*.sh tools/tests/*.sh
- run: ./tools/tests/test-portal-hardware-cli.sh
- run: ./scripts/check-docs.sh
compile-tests:
runs-on: ubuntu-latest
strategy:
@@ -20,4 +36,5 @@ jobs:
with:
python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19
- run: ./scripts/compile-check.sh --platform ${{ matrix.environment }}
- run: ./scripts/test.sh compile --platform ${{ matrix.environment }}
- 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 }}
+4
View File
@@ -33,3 +33,7 @@ Thumbs.db
# Misc
node_modules/
# Local portal station handoff credentials and optional direct test artifacts
/test/portal-station.env
/tests/portal-contract/artifacts/
+31 -1
View File
@@ -1,5 +1,36 @@
# Changelog
## 3.2.3
- Clarify the public integration boundary, built-in browser protocol, and
portal presentation documentation without coupling the standalone library to
DeviceFramework.
- Refresh the standalone test guidance and pin the packaged DFTE dependency to
the validated 1.2.1 release.
## 3.2.2
- Align the direct ESP8266 and ESP32 test environments with the packaged DFTE 1.2.0 dependency.
## 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.
@@ -29,4 +60,3 @@
- Establish `device-framework` as the independently maintained canonical branch.
- Add safe default parameter construction and allocation-failure handling.
- Pin the DFTE dependency used by PlatformIO builds.
+56 -225
View File
@@ -1,18 +1,15 @@
# WiFiManager
This repository is a **breaking fork** of upstream [`tzapu/WiFiManager`](https://github.com/tzapu/WiFiManager).
It is not a drop-in replacement for upstream behavior, APIs, templates, or portal customization patterns.
WiFiManager 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. |
## Basic provisioning
For a standalone Arduino project, create one long-lived `WiFiManager` and call
`autoConnect` during setup. It first tries saved station credentials; when that
fails it starts the configuration portal with the supplied AP name and password.
## Start with a working portal
```cpp
#include <Arduino.h>
@@ -22,247 +19,81 @@ WiFiManager wifi;
void setup() {
Serial.begin(115200);
wifi.setConfigPortalTimeout(180); // seconds; 0 leaves it open
if (!wifi.autoConnect("Device Setup", "change-me")) {
ESP.restart();
}
wifi.setConfigPortalTimeout(180);
wifi.autoConnect("Device Setup", "change-me");
}
void loop() {
// Normal application work after WiFi is connected.
wifi.process();
}
```
The AP password must meet the Wi-Fi password requirements. For applications
built on DeviceFramework, configure `setConfigDevicePassword(...)` instead:
DeviceFramework passes the same validated password to WiFiManager, Arduino OTA,
HTTP Basic authentication, and WebSerial.
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.
## 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
## Make it yours
```cpp
WiFiManager wm;
const char kTitle[] PROGMEM = "Set up Temperature Monitor";
const char kBrand[] PROGMEM = "Example Devices";
const char kAccent[] PROGMEM = "#347a45";
wm.portalSetBrandTitle("Solar Battery Monitor Setup");
wm.portalSetContextIdentityText("Solar Battery Monitor");
wm.portalSetBrandHomeIntro(
"Connect your monitor to WiFi, then review battery and inverter settings."
);
wm.portalSetBrandLogoSvg(
"<svg viewBox='0 0 24 24' aria-hidden='true'>"
"<path d='M12 2L4 12h5l-1 10 8-10h-5z'></path>"
"</svg>"
);
void setup() {
WiFiManagerPortalConfig portalUI;
wm.portalSetPageInfoVisible(true);
wm.portalSetPageUpdateVisible(false);
wm.portalSetActionEraseVisible(false);
wm.portalSetActionRestartVisible(true);
wm.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage);
// Leave fields unset to keep WiFiManager's built-in portal values.
portalUI.title = WiFiManagerPortalText::progmem(kTitle);
portalUI.identityText = WiFiManagerPortalText::progmem(kBrand);
wm.portalSetBehaviorCaptivePortalEnabled(true);
wm.portalSetBehaviorConnectOnSave(true);
wm.portalSetBehaviorExitAllowed(true);
// Theme values customise the built-in styles; they do not replace the portal.
portalUI.theme.accent = WiFiManagerPortalText::progmem(kAccent);
portalUI.theme.cornerRadiusPx = 10;
wm.portalSetFieldPasswordPlaceholderMode(PortalPasswordPlaceholderMode::Masked);
wm.portalSetFieldStaticIpVisibility(PortalFieldVisibility::Auto);
wm.portalSetFieldStaticDnsVisibility(PortalFieldVisibility::Auto);
wifi.setPortalConfig(portalUI); // Apply before the portal starts.
wifi.autoConnect("Device Setup");
}
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:
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.
- `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.
## What it provides
## Dependencies
- **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:** C++ configuration and a local JSON protocol for the built-in portal.
- **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.
This fork depends on **DFTE** ([Device Framework Template Engine](https://github.com/alexhopeoconnor/DFTE)) and **ESP32Async/ESPAsyncWebServer**.
## Choose a guide
The published `library.json` resolves DFTE from the maintained, pinned
`v1.0.2` Git tag. No sibling checkout is required to consume WiFiManager.
When developing both libraries together, use a local `symlink://` or
`file://` dependency in your own ignored PlatformIO override.
| Goal | Guide |
| --- | --- |
| Understand library/application ownership and supported boundaries | [Architecture](docs/ARCHITECTURE.md) |
| Get a device online or recover from missing Wi-Fi | [Provisioning lifecycle](docs/PROVISIONING_LIFECYCLE.md) |
| Brand or constrain the built-in portal | [Portal UI and configuration](docs/PORTAL_UI.md) |
| Add and persist application settings, status, or home cards | [Portal content](docs/PORTAL_CONTENT.md) |
| Configure primary/fallback station profiles | [Station profiles](docs/STATION_PROFILES.md) |
| Surface setup, offline, and connection state in firmware | [Observability](docs/OBSERVABILITY.md) |
| Configure AP, station, scan, or reconnect behaviour | [Network configuration](docs/NETWORK_CONFIGURATION.md) |
| Look up a supported C++ method and its timing | [API reference](docs/API_REFERENCE.md) |
| Understand the built-in portal's local JSON protocol | [Portal API](docs/PORTAL_API.md) |
| Follow deployment-oriented integration patterns | [Recipes](docs/recipes/README.md) |
| Build or flash a complete example | [Examples](examples/README.md) |
| Run documentation and board-free compile checks | [Testing](docs/TESTING.md) |
| Contribute to this fork or prepare a release | [Development](docs/DEVELOPMENT.md) |
## Installation
Use the release tag. The package manifest resolves DFTE, the asynchronous web
server, and the platform-specific TCP library automatically:
## Install
```ini
[common]
lib_deps =
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.0.6
[env:esp8266]
extends = common
platform = espressif8266
board = d1_mini
framework = arduino
[env:esp32]
extends = common
platform = espressif32
board = esp32dev
framework = arduino
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.3
```
The text after `#` is a Git ref. PlatformIO clones the repository and checks
out that tag; it does not download a GitHub Release asset. A tag gives a
reproducible library input. Use a local checkout only while actively changing
the library:
The suffix after `#` is a Git ref. PlatformIO clones the repository and checks out that release tag; GitHub Release assets are unrelated. Arduino IDE users can install this repository as a library checkout.
```ini
lib_deps =
WiFiManager=file:///path/to/WiFiManager
```
## Tests and releases
The CI workflow compiles a minimal clean consumer project for both supported
targets. These commands need no connected board:
```bash
./scripts/compile-check.sh --platform esp8266
./scripts/compile-check.sh --platform esp32
```
Before publishing a version, update `library.json`, `CHANGELOG.md`, and this
README, run both checks, then create the annotated tag:
```bash
./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag
```
Push the branch and tag. GitHub Actions validates the PlatformIO package again
and creates the GitHub Release from that tag.
## 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).
+133
View File
@@ -0,0 +1,133 @@
# API reference
This is a task-oriented reference for the supported WiFiManager firmware API. Configure an instance during boot, keep it alive for the application's lifetime, and call process() regularly while WiFiManager may have active work.
For complete end-to-end patterns, use [Provisioning lifecycle](PROVISIONING_LIFECYCLE.md), [Portal content](PORTAL_CONTENT.md), and [Station profiles](STATION_PROFILES.md).
## Lifecycle
| API | Timing and meaning |
| --- | --- |
| autoConnect([apName, apPassword]) | Tries legacy saved station credentials. Returns true if it connected during the call; otherwise starts the enabled portal fallback and returns false. |
| startConfigPortal([apName, apPassword]) | Immediately starts a configuration AP and captive portal. |
| stopConfigPortal() | Immediately stops the configuration portal. |
| startWebPortal() / stopWebPortal() | Starts/stops the portal web server without the configuration-AP flow. |
| process() | Cooperatively services active portal, scan, station-profile, and connection state. Call from loop(); do not treat it as a hard real-time operation or rely on a fixed maximum duration. |
| getConfigPortalSSID() | Returns the current configuration AP name. |
| getConfigPortalActive() / getWebPortalActive() | Reports active configuration or web portal state. |
| setHttpPort(port) | Chooses the portal server port before starting it. |
The application owns any existing HTTP service and must release a shared port before WiFiManager starts its portal.
## Station profiles
| API | Timing and meaning |
| --- | --- |
| setStationProfileStore(store) | Supplies an application-owned durable store; WiFiManager does not own it. |
| startStationConnection([apName, apPassword]) | Loads and begins the stored primary/fallback profile flow. |
| startStationCandidate(candidate[, apName, apPassword]) | Attempts an in-memory profile set; when a store is attached, saves it only after a successful usable connection. |
| saveStationProfiles(profiles) | Deliberately stores a complete profile set without a connection verification attempt. |
| clearStationProfiles() | Clears the store and disconnects the station. |
| setStationRecoveryInterval(milliseconds) | Sets delay before profile recovery attempts after a connection loss. |
| isStationProfileMode(), getStationProfiles(), getStationStatus() | Inspects profile-controller state. |
Profile mode has exactly primary slot 0 and optional fallback slot 1. See [Station profiles](STATION_PROFILES.md) for storage rules and portal fields.
## Application settings and callbacks
| API | Timing and meaning |
| --- | --- |
| portalAddParameter(parameter) | Registers an application-owned WiFiManagerParameter; it must outlive the portal. Returns false if registration fails. |
| portalClearParameters() | Removes registered parameter pointers; it does not delete them. |
| getParameters() / getParametersCount() | Inspects registered parameters. |
| setPreSaveParamsCallback(callback) | Invoked before a parameter-only save. |
| setSaveParamsCallback(callback) | Invoked when parameters save; receives WiFiManagerRequestArgs. |
| setPreSaveConfigCallback(callback) | Invoked before a combined Wi-Fi/config save. |
| setSaveConfigCallback(callback) | Invoked after changed Wi-Fi settings connect successfully, or when break-after-config is enabled. |
| setConfigResetCallback(callback) | Invoked when Wi-Fi settings reset. |
| setAPCallback(callback) / setWebServerCallback(callback) | Invoked after the AP/config portal or web server begins. |
| setConfigPortalTimeoutCallback(callback) | Invoked when a configuration portal times out. |
| setPreOtaUpdateCallback(callback) | Invoked immediately before OTA update handling. |
WiFiManagerRequestArgs provides hasArg(), getArg(), getArgAsInt(), getArgAsFloat(), getArgAsBool(), and count(). It is a callback argument, not durable application configuration.
WiFiManagerParameter constructors accept an ID, label, default value, maximum length, optional custom HTML, and optional label placement. IDs are request field names; keep them stable and simple.
## Portal presentation and policy
| API | Timing and meaning |
| --- | --- |
| setPortalConfig(config) | Applies title, identity text, tagline, SVG logo, and semantic theme. Returns false when a portal is active or the theme is invalid. |
| portalSetPageInfoVisible(), portalSetPageUpdateVisible(), portalSetPageSetupVisible() | Controls built-in page visibility. |
| portalSetActionEraseVisible(), portalSetActionRestartVisible(), portalSetActionExitVisible(), portalSetActionCloseCaptiveVisible(), portalSetActionBackVisible() | Controls built-in action visibility. |
| portalSetLayoutParamsLocation(location) | Selects WiFiPage or SetupPage for custom parameters. |
| portalSetBehaviorCaptivePortalEnabled(), portalSetBehaviorConnectOnSave(), portalSetBehaviorExitAllowed() | Sets portal availability, post-save connection, and exit policy. |
| portalSetBehaviorConnectTimeoutSeconds(), portalSetBehaviorPortalTimeoutSeconds() | Sets connection and portal timeout behavior. |
| portalSetBehaviorAutoReconnect(), portalSetBehaviorApClientCheck(), portalSetBehaviorWebClientCheck() | Sets reconnection and activity/timeout behavior. |
| portalSetFieldPasswordPlaceholderMode(mode) | Selects Hidden, Masked, or Actual password placeholder behavior. |
| portalSetFieldStaticIpVisibility(visibility), portalSetFieldStaticDnsVisibility(visibility) | Selects Hidden, Auto, or Always static-network fields. |
| portalAddInfoSection(), portalClearInfoSections() | Adds/clears copied read-only information sections. |
| portalAddHomeCard(), portalClearHomeCards() | Adds/clears copied overview cards. |
Use [Portal UI and configuration](PORTAL_UI.md) for configuration examples and [Portal content](PORTAL_CONTENT.md) for ownership/persistence rules.
## Connection and network policy
| API | Timing and meaning |
| --- | --- |
| setConfigPortalTimeout(seconds) | Limits configuration portal lifetime; setTimeout() is deprecated alias. |
| setConnectTimeout(seconds), setConnectRetries(count) | Bounds legacy automatic connection attempts. |
| setSaveConnectTimeout(seconds), setSaveConnect(enabled) | Controls and bounds portal save-and-connect behavior. |
| setBreakAfterConfig(enabled) | Exits after a configuration submission even when it did not connect. |
| setEnableConfigPortal(enabled), setDisableConfigPortal(enabled) | Controls autoConnect() fallback/start-stop behavior. |
| setAPClientCheck(enabled), setWebPortalClientCheck(enabled) | Controls portal timeout interaction with AP/web clients. |
| setWiFiAutoReconnect(enabled), setCleanConnect(enabled) | Controls station reconnect and pre-connect disconnect behavior. |
| setHostname(), getWiFiHostname() | Sets/reads the supported target hostname. |
| setWiFiSSIDPrefix(), setWiFiAPChannel(), setWiFiAPHidden() | Configures the temporary setup AP. |
| setAPStaticIPConfig(), setSTAStaticIPConfig() | Configures AP or station static addressing. |
| setCountry(), setMinimumSignalQuality(), setRemoveDuplicateAPs(), setScanDispPerc() | Configures country and scan presentation/filter behavior. |
| setRestorePersistent(enabled) | Controls restoration of the platform Wi-Fi persistence setting. |
See [Network configuration](NETWORK_CONFIGURATION.md) for deployment constraints.
## Scan, state, and diagnostics
| API | Timing and meaning |
| --- | --- |
| requestAsyncScan(forceRefresh) | Requests a non-blocking scan. |
| getScanSnapshot(), getScanRuntimeState(), getScanState() | Returns scan lifecycle state. |
| isScanRunning(), hasValidScanResults() | Reads scan progress and cached-result validity. |
| getScanResults() | Returns WiFiManager-owned cached visible results; do not retain references after a new scan or portal shutdown. |
| getRSSIasQuality(rssi) | Converts RSSI to WiFiManager quality. |
| getLastConxResult(), getWLStatusString(), getModeString() | Formats connection and mode diagnostics. |
| hasEnteredConfigPortal(), getConfigPortalConnectState() | Reads portal-session history and last submission state. |
| isConfigPortalConnectPending(), didConfigPortalConnectSucceed(), didConfigPortalConnectFail() | Reads concise portal connection status. |
| getConfigPortalConnectStatus(), getConfigPortalConnectMessage() | Returns platform status and message for the last portal attempt. |
| setEventCallback(callback) | Receives lifecycle notifications; use getters for detailed state. |
| setLogEnabled(), setLogPrefix(), setLogOutput(), setLogSink(), getLogSink() | Configures log output and optional application-owned sink. |
See [Observability](OBSERVABILITY.md) for event meanings and product feedback.
## Reset and low-level helpers
| API | Meaning |
| --- | --- |
| disconnect() | Disconnects without erasing saved configuration. |
| resetSettings() | Clears legacy Wi-Fi settings. |
| erase([optional]) | Erases Wi-Fi configuration. |
| reboot() | Reboots the target. |
| getWiFiIsSaved(), getWiFiSSID(), getWiFiPass() | Reads legacy saved/current station values; handle credentials carefully. |
| getDefaultAPName() | Returns the default generated AP name. |
| debugSoftAPConfig(), debugPlatformInfo() | Writes diagnostic information. |
| htmlEntities(text[, whitespace]) | Escapes text for WiFiManager HTML rendering. |
| preloadWiFi(ssid, password) | Intended for fixtures or controlled integrations that deliberately skip normal station configuration. |
getServer() and getDNSServer() are exposed for testing/host integration. They are not a supported way for product firmware to add private portal routes or mutate the built-in server.
## Continue
- [Architecture and boundaries](ARCHITECTURE.md)
- [Portal API](PORTAL_API.md)
- [Examples](../examples/README.md)
Back to [documentation](README.md) · [project overview](../README.md).
+93
View File
@@ -0,0 +1,93 @@
# Integrating WiFiManager
WiFiManager runs device-local Wi-Fi setup. It tries station Wi-Fi, temporarily
hosts an access point and portal when the device cannot connect, and then lets
the firmware continue its normal work. It is not a cloud service,
remote-management system, or companion-app framework.
## What WiFiManager handles
| WiFiManager handles | Your firmware handles |
| --- | --- |
| Temporary AP, captive DNS, portal routes, Wi-Fi connection attempts, portal session state | Durable application settings, schema migration, product services, LEDs/display, reboot policy, telemetry, and access-control decisions |
| Wi-Fi credentials in the legacy flow, or profile-selection policy in station-profile mode | The durable station-profile store when profile mode is enabled |
| Structured portal presentation and built-in Wi-Fi/settings forms | Product-specific validation and the persistence of product settings |
| The local portal JSON protocol | Any separate product HTTP API or local web server |
This lets a firmware decide what configuration it needs without copying the
portal, captive-network behavior, or Wi-Fi connection flow.
## Typical boot sequence
~~~text
Firmware boot
│
├─ Load and migrate application settings
├─ Register portal branding, policy, and application parameters
├─ Start a WiFiManager connection flow
│ ├─ Connected: start normal application services
│ └─ Not connected: temporary AP + local portal
├─ Service wifi.process() while a portal or profile controller is active
└─ Persist application data and decide when to restart or resume services
~~~
Register portal configuration before calling autoConnect(), startConfigPortal(), startWebPortal(), or a station-profile start method. The active portal uses an immutable response model so that asynchronous requests cannot see a partially changed UI.
## Customise the built-in portal
WiFiManager can set product identity, named theme values, page/action
visibility, parameters, information sections, and home cards. Use its public
C++ configuration and content APIs for those tasks:
~~~cpp
WiFiManagerPortalConfig portal;
portal.title = WiFiManagerPortalText::progmem(PSTR("Set up sensor"));
wifi.setPortalConfig(portal);
~~~
The built-in portal still owns its HTML shell, routes, forms, navigation, and
captive behavior. It does not provide:
- arbitrary portal HTML-shell replacement;
- raw CSS or JavaScript injection;
- route replacement or navigation injection;
- a cloud API or supported mobile-companion integration surface.
The `/api/*` routes are the built-in portal's browser protocol. They are useful
for portal maintenance and tests, but are not a product firmware or
companion-app integration API. If a product needs a reusable portal capability,
add one focused public WiFiManager C++ API and test it on ESP8266 and ESP32.
## Application-service handoff
WiFiManager's portal server uses its configured HTTP port, 80 by default. A product already using that port must explicitly release it before WiFiManager starts a portal. Conversely, the application decides when its normal web service is safe to start after a successful connection.
Release an application-owned server before starting the portal:
~~~cpp
void beginRecovery() {
stopApplicationWebServer(); // Releases port 80 owned by the product.
wifi.startConfigPortal("Device Setup", "setup-password");
}
~~~
The application may instead use a different WiFiManager HTTP port through setHttpPort(); document that address for installers because the captive-portal redirect and station handoff will include the selected port.
## Data lifetime and persistence
WiFiManagerPortalConfig text and SVG values are non-owning. Keep RAM or PROGMEM source data alive for the entire firmware lifetime. WiFiManagerParameter instances are application-owned and must outlive the portal. Portal information sections and home cards are copied when registered.
A station-profile store is also application-owned. Its load(), save(), and clear() methods are responsible for durable storage and error handling. The profile controller chooses and verifies networks; it does not own the store or an application's migration format.
## Security and operator expectations
The configuration portal is intended for local setup. Use a Wi-Fi-valid AP password in deployed products, do not put secrets in information cards or logs, and do not expose password placeholders unless there is an explicit local-installation requirement. The portal's JSON endpoints are the built-in UI's device-local protocol; they are not an authenticated remote-management API.
## Continue
- [Provisioning lifecycle](PROVISIONING_LIFECYCLE.md)
- [Portal UI and configuration](PORTAL_UI.md)
- [Portal content](PORTAL_CONTENT.md)
- [Integration recipes](recipes/README.md)
Back to [documentation](README.md) · [project overview](../README.md).
+51
View File
@@ -0,0 +1,51 @@
# 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
```
When a physical ESP8266 and ESP32 are available, include their local lifecycle tests in the release gate. These tests remain opt-in because they flash the selected board and use its actual radio:
~~~bash
./scripts/test.sh hardware --platform esp8266 --port /dev/serial/by-id/usb-...
./scripts/test.sh hardware --platform esp32 --port /dev/serial/by-id/usb-...
~~~
When a physical ESP8266 or ESP32 and a spare USB Wi-Fi adapter are available,
run the Docker portal contract as an additional release-gate check. It is
opt-in because it flashes the selected board and temporarily joins its AP, but
it refuses the host default-route adapter and leaves Docker responsible only
for browser/API testing:
```bash
./tools/portal-hardware run --platform esp8266 --port /dev/serial/by-id/usb-... \
--client-interface wlx74da385d4165
```
See [Testing](TESTING.md#docker-portal-contract) for cleanup, artifacts, and
optional station handoff credentials.
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).
+38
View File
@@ -0,0 +1,38 @@
# 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); // Stop the portal after three minutes.
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. `setConfigPortalTimeout(180)` limits that portal to three minutes; pass `0` (the default) to leave it open. Use a Wi-Fi-valid AP password.
## PlatformIO dependency
```ini
lib_deps =
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.3
```
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 read [Provisioning lifecycle](PROVISIONING_LIFECYCLE.md) for return-state, timeout, and recovery rules.
For product presentation and settings, continue with [Portal UI and configuration](PORTAL_UI.md) and [Portal content](PORTAL_CONTENT.md).
Back to [documentation](README.md) · [project overview](../README.md).
+107
View File
@@ -0,0 +1,107 @@
# Network configuration
These APIs configure WiFiManager's AP, station, scan, and reconnection behavior. Set them during boot, before starting a connection or portal flow, so an installer sees one consistent configuration.
The current ESP8266 and ESP32 implementations apply AP static addressing through WiFi.softAPConfig() and station static addressing through WiFi.config(). Station static configuration is used by legacy connections, portal save-and-connect attempts, and the station-profile controller.
This page is an advanced deployment reference. Use these settings only when the local network and the product's operating policy require them; WiFiManager's defaults are appropriate for many devices.
## Access-point setup network
| API | Purpose |
| --- | --- |
| setWiFiSSIDPrefix(prefix) | Changes the prefix used by an automatically generated AP name. |
| setWiFiAPChannel(channel) | Selects the setup AP Wi-Fi channel. |
| setWiFiAPHidden(hidden) | Hides or advertises the setup AP SSID. |
| setAPStaticIPConfig(ip, gateway, subnet) | Sets the AP-side portal address configuration. |
| setHttpPort(port) | Uses a non-default portal HTTP port. |
| setHostname(name) | Sets the station/AP hostname as supported by the target. |
| getConfigPortalSSID() / getDefaultAPName() | Reports the actual/current setup AP name. |
A hidden AP can make field setup harder because an installer must enter the SSID manually. Treat it as a deployment decision, not a general security control. If setHttpPort() changes the default, publish the full portal address in the product's installation procedure.
## A fixed setup-portal address
Give the setup AP a fixed address when an installer procedure needs a stable local portal address:
~~~cpp
wifi.setAPStaticIPConfig(
IPAddress(192, 168, 8, 1),
IPAddress(192, 168, 8, 1),
IPAddress(255, 255, 255, 0));
wifi.setWiFiAPChannel(6);
wifi.setWiFiAPHidden(false);
~~~
This is applied before WiFiManager starts its SoftAP. The usual portal address becomes http://192.168.8.1/. If setHttpPort() selects a non-default port, include that port in the installer instructions and expect the station handoff URL to include it too.
## Station address and credential behavior
Use a station static address only when the network owner has reserved and documented it:
~~~cpp
wifi.setSTAStaticIPConfig(
IPAddress(192, 168, 20, 50),
IPAddress(192, 168, 20, 1),
IPAddress(255, 255, 255, 0),
IPAddress(192, 168, 20, 1));
~~~
| API | Purpose |
| --- | --- |
| setSTAStaticIPConfig(ip, gateway, subnet[, dns]) | Supplies a station static address and optional DNS server. |
| setCleanConnect(enabled) | Disconnects before connecting; use when the product requires a fresh association. |
| setRestorePersistent(enabled) | Controls restoration of the platform Wi-Fi persistence preference. |
| setWiFiAutoReconnect(enabled) | Enables Wi-Fi auto-reconnect behavior. |
| setCountry(countryCode) | Applies the supported Wi-Fi country setting. |
| disconnect() | Disconnects without erasing persistent settings. |
| resetSettings() | Clears legacy saved Wi-Fi settings. |
| erase([optional]) | Erases Wi-Fi configuration and schedules the configured reset behavior. |
Static-IP entry fields can be configured independently of the network setting:
~~~cpp
wifi.portalSetFieldStaticIpVisibility(PortalFieldVisibility::Auto);
wifi.portalSetFieldStaticDnsVisibility(PortalFieldVisibility::Hidden);
~~~
Auto shows these fields when a static station configuration is already in use; Always makes them visible; Hidden suppresses them. Do not expose static network controls in a general user portal unless the installer is expected to manage those values.
## Scan behavior
WiFiManager's portal schedules asynchronous scans. Firmware that needs its own nearby-network status can request and inspect the same cached scan state:
~~~cpp
wifi.requestAsyncScan();
if (wifi.hasValidScanResults()) {
for (const auto& network : wifi.getScanResults()) {
Serial.printf("%s: %ld dBm\n", network.ssid.c_str(), network.rssi);
}
}
~~~
| API | Purpose |
| --- | --- |
| requestAsyncScan(forceRefresh) | Queues a scan without blocking the application loop. |
| getScanSnapshot() / getScanRuntimeState() | Returns the complete scan lifecycle snapshot. |
| getScanState() / isScanRunning() / hasValidScanResults() | Provides concise status checks. |
| getScanResults() | Returns the cached visible network list. |
| setMinimumSignalQuality(quality) | Filters low-quality scan results. |
| setRemoveDuplicateAPs(enabled) | Controls duplicate SSID removal. |
| setScanDispPerc(enabled) | Uses percentage rather than quality icons in the portal. |
| getRSSIasQuality(rssi) | Converts RSSI for display. |
WiFiManager owns the scan cache. It keeps the cache and its allocation while a portal is active so nearby networks can be rendered without repeated allocation churn; it clears and releases that storage when the portal fully closes. Treat getScanResults() as a snapshot and do not retain references, iterators, or pointers across a new scan or portal shutdown.
## Connection and timeout policy
See [Provisioning lifecycle](PROVISIONING_LIFECYCLE.md) for setConfigPortalTimeout(), setConnectTimeout(), setConnectRetries(), setSaveConnectTimeout(), setSaveConnect(), and the portal-prefixed behavior equivalents. These settings define recovery behavior; they should reflect how long an on-site installer can reasonably work and how long the product can remain offline.
## Continue
- [Portal UI and configuration](PORTAL_UI.md)
- [Provisioning lifecycle](PROVISIONING_LIFECYCLE.md)
- [API reference](API_REFERENCE.md)
Back to [documentation](README.md) · [project overview](../README.md).
+89
View File
@@ -0,0 +1,89 @@
# Observability
WiFiManager exposes connection and portal state so product firmware can provide useful local feedback. Use getters as the source of truth and treat callbacks as notifications.
A device framework consuming WiFiManager uses this pattern to distinguish normal operation, offline recovery, active setup, and a successful portal connection before it changes LED state or starts product services.
## Portal state
| API | Meaning |
| --- | --- |
| getConfigPortalActive() | A configuration AP/portal is currently active. |
| getWebPortalActive() | A manually started portal web service is active. |
| hasEnteredConfigPortal() | Setup has been entered at least once since boot. |
| getConfigPortalConnectState() | Idle, queued, waiting, success, or failed for the most recent portal connection attempt. |
| isConfigPortalConnectPending() | A concise check for queued or waiting connection work. |
| didConfigPortalConnectSucceed() / didConfigPortalConnectFail() | Result checks for the most recent portal submission. |
| getConfigPortalConnectStatus() | Platform Wi-Fi status for that attempt. |
| getConfigPortalConnectMessage() | Human-readable status; suitable for logs or local display. |
| getLastConxResult() / getWLStatusString() | Legacy and general Wi-Fi connection result helpers. |
Use these values to drive product feedback. Do not derive state by scraping portal HTML or assuming that an autoConnect() false return means the portal session failed.
## Events
setEventCallback() installs an optional notification hook:
~~~cpp
wifi.setEventCallback([](WiFiManager::wm_event_t event) {
switch (event) {
case WiFiManager::WM_EVENT_PORTAL_STARTED:
setIndicator(IndicatorState::Setup);
break;
case WiFiManager::WM_EVENT_PORTAL_CONNECT_SUCCESS:
setIndicator(IndicatorState::ConnectingComplete);
break;
case WiFiManager::WM_EVENT_STATION_LINK_LOST:
setIndicator(IndicatorState::Offline);
break;
default:
break;
}
});
~~~
| Event | Product use |
| --- | --- |
| WM_EVENT_PORTAL_STARTED / WM_EVENT_PORTAL_STOPPED | Begin or end setup feedback. |
| WM_EVENT_PORTAL_CONNECT_QUEUED / START / SUCCESS / FAILED | Show progress and result for a portal-submitted network. |
| WM_EVENT_STATION_PROFILE_ATTEMPT / CONNECTED / FAILED | Track profile-controller progress. |
| WM_EVENT_STATION_LINK_LOST / BACKOFF | Show recovery behavior after an established connection drops. |
| WM_EVENT_STATION_PROFILES_CLEARED | Reconcile product state after profiles are cleared. |
Events are intentionally small and do not carry credentials or mutable request state. Read the relevant getter inside the callback when more detail is needed.
## Station-profile status
When using profile mode, getStationStatus() reports:
- state: idle, loading, attempting, switching, connected, backoff, or portal;
- activeSlot and attemptedSlot;
- configuredProfiles;
- wifiStatus and a human-readable message;
- whether the last connection was a candidate;
- whether the profile store could not save a successful candidate.
A store-save failure is operationally important: the device may be connected now but will not necessarily reconnect after a restart. Preserve that distinction in an LED, display, or operator log.
## Logging
WiFiManager logs to its configured Print output unless a WiFiManagerLogSink is supplied.
~~~cpp
wifi.setLogPrefix("[network] ");
wifi.setLogOutput(true, WiFiManagerLogLevel::Info);
~~~
WiFiManagerLogLevel ranges from Silent through Error, Warn, Info, Debug, and Trace. A custom WiFiManagerLogSink receives a WiFiManagerLogMessage instead of the Print output. Redact SSIDs and never log passwords, portal form values, or product secrets into a remotely collected log.
## Scan status
For nearby-network progress, use getScanState(), isScanRunning(), hasValidScanResults(), and getScanResults(); see [Network configuration](NETWORK_CONFIGURATION.md#scan-behavior). The portal API exposes a matching local scan-status representation for the built-in UI.
## Continue
- [Provisioning lifecycle](PROVISIONING_LIFECYCLE.md)
- [Station profiles](STATION_PROFILES.md)
- [API reference](API_REFERENCE.md)
Back to [documentation](README.md) · [project overview](../README.md).
+114
View File
@@ -0,0 +1,114 @@
# Built-in portal browser protocol
This device-local protocol is used by WiFiManager's built-in portal shell. It
is useful when maintaining that UI or writing portal-focused tests. It is not a
cloud API, remote-management interface, or supported companion-app integration
surface.
The portal is unauthenticated local setup infrastructure. Do not expose it as a product's general application API, and do not infer a security boundary from hiding an action in the UI.
## Contract version and format
GET /api/bootstrap returns contractVersion 3. The built-in portal uses that versioned response; tests that assert its shape should feature-detect fields rather than assume undocumented portal HTML or JSON properties.
All documented API responses are JSON with no-cache headers. The server also sends permissive CORS headers for the built-in portal implementation; that does not change the local-only security boundary or make these routes a remote integration contract.
## Route inventory
| Method | Route | Built-in portal purpose |
| --- | --- | --- |
| GET | / | Serves the one portal HTML shell. |
| GET | /api/bootstrap | Reads product brand, portal context, page/action visibility, layout, scan summary, and overview cards. |
| GET | /api/wifi/scan-status | Reads async scan state and visible nearby networks. |
| POST | /api/wifi/scan | Queues a forced asynchronous scan; returns 202. |
| GET | /api/wifi/meta | Reads Wi-Fi form fields, static-IP fields, parameters on the Wi-Fi page, and profile metadata when enabled. |
| POST | /api/wifi/save | Submits Wi-Fi fields, applicable parameters, and optionally a station-profile set. |
| GET | /api/wifi/connect-status | Reads the result of the most recent portal save-and-connect attempt. |
| POST | /api/wifi/connect-complete | Acknowledges that the built-in portal has received the successful station handoff address. |
| POST | /api/portal/timeout-reset | Restarts an active configured portal timeout. |
| GET | /api/params | Reads parameters for the separate Setup page. |
| POST | /api/params/save | Saves Setup-page parameters. |
| GET | /api/info | Reads device/Wi-Fi info, copied information sections, and visible actions. |
| GET | /api/status | Reads a short plain-text status summary in JSON. |
| POST | /api/device/restart | Schedules a target restart. |
| POST | /api/device/erase | Erases Wi-Fi configuration and schedules restart when successful. |
| POST | /api/portal/close | Disables captive-portal detection for the active session. |
| POST | /api/portal/exit | Requests portal exit when portal exit is allowed. |
| POST | /u | Receives multipart firmware upload and returns JSON completion status. |
Unknown routes are redirected only while captive-portal handling is active; otherwise they return the normal not-found result.
## Bootstrap and read models
Bootstrap includes a brand object (title, tagline, logo SVG, and logo alt text), a context object (portal activity, timeout remaining, identity text, status summary, scan state), visible pages/actions, parameter layout, and copied home cards.
Wi-Fi form metadata is intentionally separate:
- Legacy mode returns SSID/password fields, configured static fields, and parameters when the layout places them on the Wi-Fi page.
- Profile mode returns primary/fallback profile metadata, the active slot, controller state, static fields, and applicable parameters.
- Password values are never returned. Legacy password placeholder behavior is controlled by portalSetFieldPasswordPlaceholderMode().
GET /api/params exposes all registered parameters for the separate Setup page, plus whether the back action is visible. GET /api/info exposes device/Wi-Fi facts, copied information sections, and action visibility. GET /api/status returns a short human-readable text field.
## Wi-Fi submit and connection handoff
POST /api/wifi/save accepts form fields. In legacy mode:
| Field | Meaning |
| --- | --- |
| s | Station SSID. |
| p | Station password. A password without an SSID is treated as a password change for the stored SSID. |
| ip, gw, sn, dns | Optional station static IP, gateway, subnet, and DNS values when those fields are visible. |
| Registered parameter ID or param_N | Application parameter values when parameters are placed on the Wi-Fi page. |
A normal accepted save returns 202 and directs the portal to poll /api/wifi/connect-status. The status response is:
~~~json
{
"state": "idle | 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 success. If WiFiManager uses a non-default HTTP port, redirectUrl includes it.
After observing success, the built-in portal POSTs /api/wifi/connect-complete. A 409 response means successful handoff is not ready; otherwise WiFiManager keeps the portal alive briefly, receives the acknowledgement, and then closes after a grace delay. Browser captive redirects can still fail, so the portal keeps the station address visible.
When portalSetBehaviorConnectOnSave(false) or setSaveConnect(false) is selected, a Wi-Fi save does not start this station connection/handoff flow.
## Profile-mode submit
In profile mode, POST /api/wifi/save accepts s0/p0 for primary and s1/p1 for fallback. Primary must be non-empty. A blank p0 or p1 preserves an existing password; clear0 or clear1 explicitly clears a password for an open network.
By default, WiFiManager attempts the submitted candidate and returns 202 for status polling. With stationAction=save, it writes the submitted profile set for a later connection attempt and returns 200. See [Station profiles](STATION_PROFILES.md) for candidate verification and storage behavior.
## Parameter submit
POST /api/params/save sends registered application parameter fields and returns a successful acknowledgement after the registered save callbacks run. A parameter can be addressed by its stable ID or by param_N index. WiFiManager does not define application validation or durable-storage semantics; the consuming application owns both.
## Actions and error states
| Route | Success | Important non-success response |
| --- | --- | --- |
| POST /api/wifi/scan | 202 with accepted/queued state | Scan result arrives through scan-status. |
| POST /api/wifi/save | 202 for queued connection, or 200 for profile save-for-later | 400 for invalid Wi-Fi/profile input; 500 when an explicit profile store save fails. |
| POST /api/wifi/connect-complete | 200 after successful station handoff | 409 when success/address is not ready. |
| POST /api/portal/timeout-reset | 200 with timeout seconds remaining | 409 when no active finite portal timeout exists. |
| POST /api/device/restart | 200, restart scheduled | The target restarts shortly after the response. |
| POST /api/device/erase | 200, erase/restart scheduled | 500 when erase fails. |
| POST /api/portal/close | 200, captive detection disabled | The portal server itself remains subject to its normal lifecycle. |
| POST /api/portal/exit | 200, exit scheduled | 403 when exit is not allowed. |
| POST /u | 200, firmware update/restart scheduled | 500 with update failure detail. |
## Using this protocol safely
Use this document to understand and test WiFiManager's own portal behavior.
Applications that need a product web API should host and secure that API after
WiFiManager has completed its provisioning role. Do not scrape the portal shell,
depend on undocumented JSON fields, or add routes through WiFiManager's testing
server accessor.
Back to [documentation](README.md) · [project overview](../README.md).
+107
View File
@@ -0,0 +1,107 @@
# Portal content and application settings
Use the built-in portal to collect small product settings alongside Wi-Fi credentials. WiFiManager owns the form and its temporary values; the application validates and persists its own settings.
This matches the product-firmware pattern used by real consumers: load configuration first, expose its current values through WiFiManagerParameter objects, then persist valid submitted values through the application's configuration layer.
## End-to-end pattern
~~~cpp
#include <WiFiManager.h>
constexpr int kBrokerHostLength = 64;
WiFiManager wifi;
WiFiManagerParameter brokerHost(
"broker_host", "MQTT broker", "", kBrokerHostLength);
void setupPortal() {
// The application has already loaded settings before this point.
brokerHost.setValue(settings.mqttHost.c_str(), kBrokerHostLength);
wifi.portalAddParameter(&brokerHost);
wifi.setSaveParamsCallback([](WiFiManager::WiFiManagerRequestArgs) {
const String candidate = brokerHost.getValue();
if (!isValidHostname(candidate)) {
logInvalidBroker(candidate);
return; // Keep the application's known-good stored value.
}
settings.mqttHost = candidate;
saveApplicationSettings(settings);
});
}
~~~
Call setupPortal() after loading application settings and before a WiFiManager connection or portal start method. The callback receives a copy of the submitted request arguments; use getArg(), getArgAsInt(), getArgAsFloat(), or getArgAsBool() when the application needs request-level values.
The save-parameters callback is a notification hook, not a validation-response API. Its return type cannot turn the built-in UI's success response into a form error. Validate before using the candidate, preserve known-good data when persistence fails, and provide product-specific feedback through the application's own UI/logging policy.
## Parameter rules
| Rule | Why it matters |
| --- | --- |
| Keep each WiFiManagerParameter alive while the portal can use it. | WiFiManager stores the parameter pointer; it does not take ownership. |
| Use a stable ID without spaces or special characters. | The ID is used in portal requests. |
| Choose a bounded value length. | It defines the editable buffer length. |
| Register parameters before opening the portal. | Active portal responses use their established form model. |
| Treat submitted text as untrusted application input. | Portal input is not application validation or durable storage. |
Use portalClearParameters() only before opening a portal when rebuilding a complete form. It removes registered parameter pointers; it does not destroy application-owned parameter objects.
## Where settings appear
By default, custom parameters appear on the Wi-Fi page. To put them on the separate Setup page:
~~~cpp
wifi.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage);
~~~
Use the Wi-Fi page for a small setting that is naturally provisioned with network credentials. Use the Setup page when product configuration needs its own step. Page visibility and layout are part of the structured portal policy; see [Portal UI and configuration](PORTAL_UI.md).
## Read-only product context
Use information sections for labelled facts and home cards for a short overview or callout:
~~~cpp
PortalInfoSection deviceInfo;
deviceInfo.id = "device";
deviceInfo.title = "Device";
deviceInfo.items = {
{"firmware", "Firmware", firmwareVersion},
{"sensor", "Sensor", sensorReady ? "Ready" : "Checking"},
};
wifi.portalAddInfoSection(deviceInfo);
PortalHomeCard installerHint;
installerHint.id = "installer-hint";
installerHint.title = "Before you begin";
installerHint.kind = PortalHomeCardKind::Callout;
installerHint.text = "Connect the device to its final local network.";
wifi.portalAddHomeCard(installerHint);
~~~
Information sections and cards are copied at registration. Do not place secrets, passwords, API tokens, or personally identifying values in them.
## Save callbacks
| Callback | Use |
| --- | --- |
| setPreSaveParamsCallback() | Observe a parameter-only save before the normal parameter callback. |
| setSaveParamsCallback(args) | Read, validate, and persist application settings after a parameter save. |
| setPreSaveConfigCallback() | Observe a combined Wi-Fi/config save before Wi-Fi connection processing. |
| setSaveConfigCallback() | React after Wi-Fi settings changed and the connection succeeded, or when break-after-config is enabled. |
| setConfigResetCallback() | Clear or reconcile application configuration when the portal resets Wi-Fi settings. |
Do not use a Wi-Fi-success callback to persist unrelated product settings: parameters can be saved separately, and an application needs its own durable-data policy.
The buildable [Custom Portal Content](../examples/CustomPortalContent/) example demonstrates parameters, information sections, and home cards. This guide supplies the missing persistence and validation boundary.
## Continue
- [Portal UI and configuration](PORTAL_UI.md)
- [Product settings and Wi-Fi recipe](recipes/PRODUCT_SETTINGS_AND_WIFI.md)
- [API reference](API_REFERENCE.md)
Back to [documentation](README.md) · [project overview](../README.md).
+124
View File
@@ -0,0 +1,124 @@
# Portal UI and configuration
WiFiManagerPortalConfig is the supported presentation API for the built-in provisioning portal. It changes product identity and semantic visual tokens; the portal-policy methods below control the visibility and behavior of existing built-in features. WiFiManager continues to own portal routes, forms, navigation, captive behavior, reset, and OTA views.
Apply presentation before autoConnect(), startConfigPortal(), or startWebPortal(). Configure portal policy during boot as well so each portal session begins consistently. Portal text and SVG assets are non-owning, so their RAM or PROGMEM data must have static firmware lifetime. WiFiManager locks presentation while a portal is active so asynchronous responses cannot observe partial configuration; setPortalConfig() returns false if it cannot accept the 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;
if (!wifi.setPortalConfig(kPortalUI)) {
Serial.println("Portal UI configuration was rejected");
}
wifi.autoConnect("Temperature Monitor");
}
void loop() { wifi.process(); }
~~~
The complete buildable example is [Branded Portal](../examples/BrandedPortal/BrandedPortal.ino). The compile fixture exercises this API on ESP8266 and ESP32.
## Presentation reference
Leave a text or colour value empty, or a radius at 0, to retain the built-in stylesheet value.
| Field | Used by |
| --- | --- |
| title | Document title and concise setup-page heading |
| identityText | Company or product name in the header above navigation |
| tagline | Short product context 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 simple named CSS values and are emitted once into a small
portal theme block. Raw CSS and JavaScript are not supported. An SVG is a
trusted compiled firmware asset, never form, MQTT, or network input.
## Portal policy
Use the portal-prefixed methods to choose which built-in pages and actions a
product presents. Configure them during boot, before the portal starts, so a
session begins with the intended behavior.
~~~cpp
// An installer portal that does not expose destructive reset or OTA actions.
wifi.portalSetPageUpdateVisible(false);
wifi.portalSetActionEraseVisible(false);
wifi.portalSetActionRestartVisible(false);
// Keep application settings on their own Setup page.
wifi.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage);
// Do not show saved passwords or static-IP fields unless the product needs them.
wifi.portalSetFieldPasswordPlaceholderMode(PortalPasswordPlaceholderMode::Hidden);
wifi.portalSetFieldStaticIpVisibility(PortalFieldVisibility::Hidden);
wifi.portalSetFieldStaticDnsVisibility(PortalFieldVisibility::Hidden);
~~~
| Group | Methods | Use |
| --- | --- | --- |
| Pages | portalSetPageInfoVisible(), portalSetPageUpdateVisible(), portalSetPageSetupVisible() | Show only product-appropriate built-in pages. |
| Actions | portalSetActionEraseVisible(), portalSetActionRestartVisible(), portalSetActionExitVisible(), portalSetActionCloseCaptiveVisible(), portalSetActionBackVisible() | Control existing action affordances; hiding an action is not a security boundary. |
| Layout | portalSetLayoutParamsLocation() | Put registered parameters on the Wi-Fi page or separate Setup page. |
| Connection/portal behavior | portalSetBehaviorCaptivePortalEnabled(), portalSetBehaviorConnectOnSave(), portalSetBehaviorExitAllowed(), portalSetBehaviorConnectTimeoutSeconds(), portalSetBehaviorPortalTimeoutSeconds(), portalSetBehaviorAutoReconnect(), portalSetBehaviorApClientCheck(), portalSetBehaviorWebClientCheck() | Set built-in portal behavior. |
| Fields | portalSetFieldPasswordPlaceholderMode(), portalSetFieldStaticIpVisibility(), portalSetFieldStaticDnsVisibility() | Limit password disclosure and network-field visibility. |
The older setConfigPortalTimeout(), setSaveConnect(), setShowStaticFields(), and related methods remain available. Prefer a single vocabulary within a product; the portal-prefixed methods make the policy visible in the portal's structured model.
## Structured content
Use portalAddParameter() for editable product settings, portalAddInfoSection() for labelled read-only values, and portalAddHomeCard() for overview text or key/value cards. Parameters remain application-owned; information sections and cards are copied when registered.
See [Portal content](PORTAL_CONTENT.md) for the full persistence, validation, callback, and lifetime rules. The buildable [Custom Portal Content](../examples/CustomPortalContent/) example shows all three content types.
## What stays built in
Branding, policy, and structured content configure the supplied portal. The
portal's HTML shell, routes, navigation, stylesheet, and scripts stay owned by
WiFiManager. There is no custom shell, route replacement, navigation injection,
raw stylesheet, or script hook.
For a product-specific web application, start that application's own server
after WiFiManager has completed provisioning. If the supplied portal needs a
reusable capability, add one focused public WiFiManager C++ API and test it on
ESP8266 and ESP32.
Back to the [documentation index](README.md) or [project overview](../README.md).
+92
View File
@@ -0,0 +1,92 @@
# Provisioning lifecycle
Choose a connection flow that matches the device's operating policy, then call process() regularly for as long as WiFiManager is active. It is a cooperative service call, not a hard real-time guarantee: target Wi-Fi or DNS work can occasionally take longer than a typical loop iteration. The consuming firmware owns its product-service lifecycle and restart decision.
## Choose a flow
| Need | Preferred API | What it does |
| --- | --- | --- |
| One saved station network, with portal fallback | autoConnect() | Tries saved credentials and starts the configuration portal when the attempt cannot connect. |
| Primary plus fallback network with application-owned storage | setStationProfileStore() + startStationConnection() | Loads, selects, retries, and recovers a fixed two-profile station set. |
| Verify a received profile before retaining it | startStationCandidate() | Attempts a primary/fallback candidate in memory and saves it only after success. |
| Start setup under application control | startConfigPortal() | Starts an AP and captive configuration portal immediately. |
| Show the portal while station Wi-Fi is already available | startWebPortal() | Starts the portal web service without starting a configuration AP. |
The first two are the normal deployed-device flows. StartConfigPortal() and startWebPortal() are supported control APIs, but should be used only when the application has a clear policy for entering and leaving them.
## Basic recovery flow
~~~cpp
#include <Arduino.h>
#include <WiFiManager.h>
WiFiManager wifi;
void setup() {
Serial.begin(115200);
wifi.setConfigPortalTimeout(180);
if (wifi.autoConnect("Device Setup", "change-me")) {
startNormalApplication();
} else {
Serial.println("Wi-Fi setup portal is active");
}
}
void loop() {
wifi.process();
if (wifi.didConfigPortalConnectSucceed()) {
// The application decides whether to start services or reboot.
startNormalApplication();
}
}
~~~
autoConnect() returns true when WiFiManager connected during that call. It returns false when it could not connect and has entered the portal path; false is not, by itself, an instruction to restart. Call process() every loop iteration while setup may be needed.
A portal timeout is in seconds. setConfigPortalTimeout(0), the default, leaves the portal open. A field-installed product may deliberately use a longer bounded window, such as 15 minutes, so an installer has time to complete setup without leaving an unattended portal indefinitely.
## Portal connection outcome
For a portal save-and-connect attempt, use these getters instead of inferring state from the rendered page:
| Getter | Meaning |
| --- | --- |
| getConfigPortalActive() | The configuration portal is currently running. |
| hasEnteredConfigPortal() | The portal has been entered at least once this runtime session. |
| isConfigPortalConnectPending() | A portal-submitted station connection is queued or waiting. |
| didConfigPortalConnectSucceed() / didConfigPortalConnectFail() | Result of the last portal-submitted connection attempt. |
| getConfigPortalConnectStatus() / getConfigPortalConnectMessage() | Platform Wi-Fi status and a human-readable result. |
The local portal UI obtains the same state through its documented local API. Do not parse portal HTML to determine connection state.
## Timing and policy
Set these before the flow starts:
| API | Use |
| --- | --- |
| setConfigPortalTimeout(seconds) | Limits a captive configuration session; setTimeout() is its deprecated alias. |
| setConnectTimeout(seconds) and setConnectRetries(count) | Bounds legacy automatic connection attempts. |
| setSaveConnectTimeout(seconds) | Bounds a portal save-and-connect attempt. |
| setSaveConnect(enabled) | Controls whether a normal portal save attempts a station connection. |
| setBreakAfterConfig(enabled) | Exits after a configuration submission even if the connection was unsuccessful. |
| setEnableConfigPortal(enabled) / setDisableConfigPortal(enabled) | Controls autoConnect() portal fallback and its post-save shutdown behavior. |
| setAPClientCheck(enabled) / setWebPortalClientCheck(enabled) | Controls whether AP/web-client activity affects the portal timeout. |
The portal-prefixed behavior methods provide the same configuration through the structured portal contract; prefer one vocabulary consistently in a product.
## Port ownership and clean handoff
Before beginning a portal on port 80, stop any application server that already owns that port. After a portal connection succeeds, wait for the application's own readiness requirements before starting its server again. A successful station connection does not automatically make an application-level service ready.
See [Local web-service handoff](recipes/LOCAL_WEB_SERVICE_HANDOFF.md) for the integration sequence.
## Continue
- [Station profiles](STATION_PROFILES.md)
- [Observability](OBSERVABILITY.md)
- [API reference](API_REFERENCE.md)
Back to [documentation](README.md) · [project overview](../README.md).
+20
View File
@@ -0,0 +1,20 @@
# WiFiManager documentation
| I want to… | Read |
| --- | --- |
| See what WiFiManager handles and what the firmware handles | [Integrating WiFiManager](ARCHITECTURE.md) |
| Start a basic portal or install a released dependency | [Getting started](GETTING_STARTED.md) |
| Choose and operate a provisioning flow | [Provisioning lifecycle](PROVISIONING_LIFECYCLE.md) |
| Brand or constrain the built-in portal | [Portal UI and configuration](PORTAL_UI.md) |
| Add and persist application settings, status, or home cards | [Portal content](PORTAL_CONTENT.md) |
| Configure primary/fallback station profiles or their portal workflow | [Station profiles](STATION_PROFILES.md) |
| Configure AP, station, scan, and reconnect settings | [Network configuration](NETWORK_CONFIGURATION.md) |
| Surface portal and station state in product firmware | [Observability](OBSERVABILITY.md) |
| Look up a supported C++ method and lifecycle rule | [API reference](API_REFERENCE.md) |
| Maintain or test the built-in portal's browser protocol | [Portal browser protocol](PORTAL_API.md) |
| Apply an existing device-integration pattern | [Recipes](recipes/README.md) |
| Run documentation and board-free compile checks | [Testing](TESTING.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).
+90
View File
@@ -0,0 +1,90 @@
# Station profiles
WiFiManager 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.
Profile mode is enabled only when the application supplies a WiFiManagerStationProfileStore. The store may be an in-memory implementation for a temporary session, but durable deployments should provide persistent storage.
## 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 or another application subsystem 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. WiFiManager 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 to false for an open network.
When load() returns false, WiFiManager treats the profile set as unavailable and opens the normal configuration portal. When save() or clear() returns false, getStationStatus().storageSaveFailed is set and the status message explains the failure.
## Verified candidate flow
Use startStationCandidate(candidate) when another application subsystem supplies a complete primary/fallback proposal:
~~~cpp
wifi.setStationProfileStore(&profiles);
if (!wifi.startStationCandidate(candidate, "Example Setup", "setup-password")) {
reportInvalidProfileCandidate();
}
~~~
WiFiManager tries the candidate in memory, then calls the attached store only after the station connects and has a usable IP address. Inspect getStationStatus() after WM_EVENT_STATION_PROFILE_CONNECTED: lastConnectionWasCandidate tells the application that this was a candidate, and storageSaveFailed distinguishes a usable but non-durable connection.
Use saveStationProfiles(profiles) only when deliberately saving without a connection check. clearStationProfiles() asks the supplied store to clear profiles and disconnects the station only after that clear succeeds.
The [Primary and fallback Wi-Fi recipe](recipes/PRIMARY_AND_FALLBACK_WIFI.md) shows why candidate verification protects known-good data.
## Portal contract
In profile mode, the existing Wi-Fi page becomes a two-profile form. It remains driven by the same local portal endpoints:
- GET /api/wifi/meta returns profiles, activeSlot, and controller state without passwords.
- 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. The built-in portal polls GET /api/wifi/connect-status for its result.
- stationAction=save stores the submitted profiles for a later connection attempt.
The portal requires a non-empty primary SSID. Its local protocol never returns a password. See [Portal API](PORTAL_API.md) for the shared connection-status response.
## Recovery and troubleshooting
| Symptom | Inspect | Meaning |
| --- | --- | --- |
| Portal starts immediately | getStationStatus().configuredProfiles and message | Store had no valid primary profile, so WiFiManager did not fall back to SDK-owned credentials. |
| Repeated network recovery | state, attemptedSlot, activeSlot, and WM_EVENT_STATION_BACKOFF | The controller is trying configured profiles with the recovery interval. |
| Candidate is connected but lost after reboot | lastConnectionWasCandidate and storageSaveFailed | The candidate connected, but the application store did not retain it. |
| Portal form rejects a save | Local API response/message | Primary profile was missing or SSID/password bounds were invalid. |
| Profiles do not clear | storageSaveFailed and message | The application store rejected clear(); fix its storage error before assuming Wi-Fi was removed. |
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).
+105
View File
@@ -0,0 +1,105 @@
# Testing
WiFiManager separates repeatable package checks from opt-in tests that flash a
real board or join a captive portal. The normal commands never need a board,
local Wi-Fi credentials, browser binary, or sibling checkout.
## Clean consumer and example builds
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. The runner removes
a prior 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
./scripts/test.sh examples --platform esp8266
./scripts/test.sh examples --platform esp32
```
CI runs these board-free checks for pull requests and pushes to the maintained
branch. It intentionally does not require attached hardware, a local network,
or Docker.
## Local hardware lifecycle tests
The Unity suite runs portal-only firmware with no Wi-Fi credentials, MQTT,
product application framework, or local profile. It verifies portal start/stop
recovery, scan-cache release, and a real asynchronous Wi-Fi scan.
Use a stable serial-by-id path rather than a changing `/dev/ttyUSB` number:
```bash
pio device list
./scripts/test.sh hardware --platform esp8266 --port /dev/serial/by-id/usb-...
./scripts/test.sh hardware --platform esp32 --port /dev/serial/by-id/usb-...
```
The runner flashes the selected board, captures normal-boot serial output with
the repository Bash helper, requires Unity's `Tests 0 Failures` and `OK`
summary, and prints lifecycle metrics. Hardware work shares a lock with the
portal contract, so two invocations cannot flash or use the same board at once.
## Docker portal contract
`test/portal-harness` is deliberately tiny portal-only firmware, not an example
or consuming application. `tools/portal-hardware` flashes it to one explicitly
selected board, joins its AP through one explicitly selected **secondary**
Wi-Fi adapter, then runs its HTTP and browser contract in a pinned Playwright
Docker image. Docker uses host networking only to reach the already-routed
portal; it never runs NetworkManager or changes host adapters.
```bash
./tools/portal-hardware doctor --client-interface wlx74da385d4165
./tools/portal-hardware run \
--platform esp8266 \
--port /dev/serial/by-id/usb-... \
--client-interface wlx74da385d4165
```
The command refuses the host default-route adapter. If the chosen secondary
adapter is already connected, require an explicit acknowledgement before it is
replaced:
```bash
./tools/portal-hardware run ... --take-over-client-adapter
```
The fixture opens a 15-minute portal session, includes one harmless custom
parameter, and verifies root/bootstrap/info/status API responses, concurrent
low-priority requests, parameter persistence, timeout reset, an actual async
scan, a missing-route response, and desktop/mobile portal rendering with no
browser page errors. Screenshots, traces on failure, JSON results, and the HTML
report are saved under the printed XDG state-directory artifact path.
On ESP8266, an AP+STA scan can briefly move the radio off the AP channel. The
client may reconnect during that interval; the contract deliberately retries
that transport interruption and still requires a reachable portal with a
complete, valid scan result.
For interactive diagnosis, leave the temporary client connection up and remove
only that managed connection when finished:
```bash
./tools/portal-hardware up --platform esp32 --port /dev/serial/by-id/usb-... \
--client-interface wlx74da385d4165
./tools/portal-hardware down
```
An optional station handoff test is deliberately separate because it connects
the fixture to a real LAN. Copy the ignored template below, add local
credentials, and pass it explicitly; it is mounted read-only into the test
container and is never logged by the runner.
A retained session is deliberately never overwritten. If a previous `up` or an
interrupted `run` left one behind, run `./tools/portal-hardware down` first;
that removes only the named temporary connection recorded by the tool.
```bash
cp test/portal-station.env.example test/portal-station.env
./tools/portal-hardware run ... --station-env test/portal-station.env
```
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

@@ -0,0 +1,63 @@
# Field installer provisioning
A field-installed controller or sensor often needs more time for setup than a desk-bound demo, but should not host an unattended configuration portal forever. Existing consuming device sketches solve this by choosing a deliberate 15-minute configuration window and exposing clear local feedback while setup is active.
## Flow
1. Boot with the product's normal configuration.
2. Try the stored primary/fallback profile set.
3. If Wi-Fi is unavailable, start the local setup AP and portal.
4. Keep the portal available for the installation window.
5. On a successful connection, let the application start services or reboot according to its own policy.
6. On timeout, return to the product's recovery policy rather than assuming the installer finished.
~~~cpp
constexpr unsigned long kInstallerWindowSeconds = 15 * 60;
MyProfileStore profileStore;
void setup() {
configureProductPortal();
wifi.setStationProfileStore(&profileStore);
wifi.setStationRecoveryInterval(30000);
wifi.setConfigPortalTimeout(kInstallerWindowSeconds);
wifi.setAPCallback([](WiFiManager*) {
setIndicator(IndicatorState::Setup);
showInstallerInstructions();
});
wifi.setConfigPortalTimeoutCallback([] {
setIndicator(IndicatorState::Offline);
recordSetupTimeout();
});
wifi.startStationConnection(deviceSetupName(), deviceSetupPassword());
}
void loop() {
wifi.process();
// Do not start or recreate an application server while the portal owns its port.
}
~~~
The timeout is a product decision. A short interval is appropriate for a user-facing appliance; a longer maintenance window may be justified for a device installed in a cabinet or plant room. Set 0 only when an always-open setup portal is an explicit operational choice.
## What to show an installer
Keep feedback independent of a browser redirect:
- the setup SSID and any required password;
- the device's portal address and non-default port, if configured;
- a distinct setup indicator while the portal is active;
- a distinct failure/offline indication after timeout;
- confirmation only after Wi-Fi has actually connected.
Use getConfigPortalActive(), didConfigPortalConnectSucceed(), and getConfigPortalConnectMessage() for this state. See [Observability](../OBSERVABILITY.md).
## Product boundary
WiFiManager supplies the local portal and timer. The product decides what happens after timeout: keep retrying profiles, sleep, wait for a physical action, or operate in an offline mode. If startStationConnection() returns false because no usable stored profile exists, the portal may be the intended next state rather than a reason to reboot immediately.
For a product that deliberately uses one platform-saved network rather than primary/fallback profiles, the smaller legacy equivalent is wifi.autoConnect(deviceSetupName(), deviceSetupPassword()). It has the same local portal fallback, but no application-owned profile store, candidate verification, or fallback network.
Continue with [Provisioning lifecycle](../PROVISIONING_LIFECYCLE.md) or [Provisioning state feedback](PROVISIONING_STATE_FEEDBACK.md).
+40
View File
@@ -0,0 +1,40 @@
# Local web-service handoff
A product may already host its own local HTTP service when a later connection failure opens WiFiManager's provisioning portal. Because WiFiManager owns its portal server port while active, the product must release that port before WiFiManager registers its portal routes.
A real consuming framework does this from the AP callback: it shuts down its normal web service after the setup AP has started but before WiFiManager registers portal routes.
## Automatic recovery handoff
~~~cpp
MyProfileStore profileStore;
void setup() {
wifi.setStationProfileStore(&profileStore);
wifi.setStationRecoveryInterval(30000);
wifi.setAPCallback([](WiFiManager*) {
stopApplicationWebServer(); // Releases port 80 for WiFiManager.
setIndicator(IndicatorState::Setup);
});
wifi.startStationConnection("Device Setup", "setup-password");
}
~~~
The AP callback runs after AP mode begins and before the portal routes are registered. It is the appropriate hook for automatic portal fallback. This profile-controller form matches the consuming firmware's recovery flow. If a product deliberately uses one platform-saved network instead, replace startStationConnection(...) with wifi.autoConnect(...); the same AP callback ordering applies. If the application explicitly starts setup itself, release its server before calling startConfigPortal().
## After a successful connection
The portal's successful Wi-Fi connection means the station has an address. It does not guarantee that the product's own web application, MQTT connection, or sensors are ready. The product should:
1. observe didConfigPortalConnectSucceed() or the matching event;
2. persist any application settings required for normal operation;
3. choose whether to restart or recreate its own server;
4. only advertise the product service when it is ready.
If the product uses a non-default WiFiManager HTTP port, it can avoid a port conflict but must treat the portal URL and its station-connect handoff URL as that non-default port.
Do not add routes to WiFiManager's internal server through getServer(). That accessor is for testing/host integration, not a supported product-extension API.
Continue with [Provisioning lifecycle](../PROVISIONING_LIFECYCLE.md) and [Observability](../OBSERVABILITY.md).
+49
View File
@@ -0,0 +1,49 @@
# Primary and fallback Wi-Fi
Some deployed devices receive a primary Wi-Fi network and an optional fallback from a local provisioning source. The consuming framework uses WiFiManager's fixed two-profile controller so it can verify a candidate connection before replacing durable known-good profiles.
## Flow
1. The application obtains a complete candidate profile set.
2. It gives the candidate to startStationCandidate().
3. WiFiManager attempts the primary profile and then fallback when needed.
4. On a usable station connection, WiFiManager saves the candidate through the application-owned store.
5. If connection or storage fails, the application can report the result without silently replacing known-good data.
~~~cpp
WiFiManagerStationProfiles candidate = makeDeploymentProfiles();
wifi.setStationProfileStore(&profileStore);
wifi.setStationRecoveryInterval(30000);
if (!wifi.startStationCandidate(candidate, "Device Setup", "setup-password")) {
reportRejectedProfileSet();
}
~~~
A candidate needs a non-empty enabled primary slot. The fallback slot is optional. WiFiManager never exposes profile passwords through the portal metadata API.
## Treat persistence result as part of success
A candidate can connect successfully while the profile store fails to write. Read getStationStatus() when WM_EVENT_STATION_PROFILE_CONNECTED arrives:
~~~cpp
wifi.setEventCallback([](WiFiManager::wm_event_t event) {
if (event != WiFiManager::WM_EVENT_STATION_PROFILE_CONNECTED) {
return;
}
const auto& status = wifi.getStationStatus();
if (status.lastConnectionWasCandidate && status.storageSaveFailed) {
reportConnectedButNotRetained();
}
});
~~~
This distinction matters in unattended devices: the device works now, but may fail to reconnect after a restart.
## Bootstrap and reconcile are application decisions
WiFiManager verifies and selects station profiles. It does not define whether a product should accept a supplied profile only on first boot, replace values after an explicit revision, or merge settings from another system. Keep that policy in the application, alongside its durable configuration and schema migration.
See [Station profiles](../STATION_PROFILES.md) for store requirements, portal fields, and the complete controller lifecycle.
+56
View File
@@ -0,0 +1,56 @@
# Product settings and Wi-Fi
A connected device often needs more than an SSID and password: a device name, broker host, endpoint, operating mode, or installer-selected option. The actual consuming framework pattern is to load those settings before WiFiManager begins, expose them as portal parameters, and persist them through the application's own storage layer.
## Flow
1. Load the application's durable settings and run its schema migration.
2. Build WiFiManagerParameter objects from the loaded values.
3. Register those parameters before a portal can start.
4. Register the save-parameters callback.
5. Validate and persist submitted application values through the application's storage layer.
6. Separately observe a successful Wi-Fi configuration/save if product services need a connected station first.
~~~cpp
constexpr int kBrokerHostLength = 64;
WiFiManagerParameter brokerHost(
"broker_host", "MQTT broker", "", kBrokerHostLength);
void configurePortalFromSettings() {
brokerHost.setValue(settings.mqttHost.c_str(), kBrokerHostLength);
wifi.portalAddParameter(&brokerHost);
wifi.setSaveParamsCallback([](WiFiManager::WiFiManagerRequestArgs) {
const String requestedHost = brokerHost.getValue();
if (!isValidHostname(requestedHost)) {
logRejectedSetting("broker_host");
return;
}
settings.mqttHost = requestedHost;
if (!saveApplicationSettings(settings)) {
reportConfigurationStorageFailure();
}
});
}
~~~
WiFiManager does not own settings, schema migration, or the storage transaction. In particular, an application should keep known-good settings if validation or storage fails.
## Keep the two persistence paths separate
| Data | Owner | Save trigger |
| --- | --- | --- |
| Wi-Fi credentials in legacy mode | WiFiManager/platform Wi-Fi | Portal Wi-Fi save and connection flow |
| Primary/fallback station profiles | Application-provided profile store | Profile controller after verified candidate connection, or explicit save |
| Product settings | Application | setSaveParamsCallback() and application validation |
| Product schema/revision | Application | Application boot/migration policy |
A profile provisioning revision is not the same thing as an application schema version. The application owns both migration and the decision to apply a configuration update.
## Form placement
Use portalSetLayoutParamsLocation(PortalParamsLocation::WiFiPage) for one or two values that naturally belong in initial connectivity setup. Use SetupPage for product configuration that deserves a separate step. Put read-only status in PortalInfoSection or PortalHomeCard rather than turning it into a parameter.
For lifetime and callback details, see [Portal content](../PORTAL_CONTENT.md).
@@ -0,0 +1,43 @@
# Provisioning state feedback
A physical device should give useful feedback without relying on a captive-browser redirect. Existing consuming firmware uses WiFiManager state to make setup, recovery, and connected states visible through an LED, display, or local log.
## State model
| Product state | WiFiManager signal | Typical feedback |
| --- | --- | --- |
| Normal connected operation | Station/profile controller connected, no configuration portal | Normal indicator and application services. |
| Trying known networks | Profile status is attempting or switching | Short recovery indication; no claim that setup is active yet. |
| Setup portal active | getConfigPortalActive() is true or WM_EVENT_PORTAL_STARTED | Installer-oriented setup indication and instructions. |
| Portal submitted credentials | isConfigPortalConnectPending() is true | Progress indication. |
| Portal connection succeeded | didConfigPortalConnectSucceed() or WM_EVENT_PORTAL_CONNECT_SUCCESS | Connected confirmation; application decides when services start. |
| Portal connection failed | didConfigPortalConnectFail() or WM_EVENT_PORTAL_CONNECT_FAILED | Failure indication with retry/recovery policy. |
| Candidate connected but not stored | Station event plus storageSaveFailed | Warning: active connection may not survive restart. |
## Polling pattern
~~~cpp
void updateIndicator() {
if (wifi.getConfigPortalActive()) {
setIndicator(IndicatorState::Setup);
} else if (wifi.isConfigPortalConnectPending()) {
setIndicator(IndicatorState::Connecting);
} else if (wifi.didConfigPortalConnectFail()) {
setIndicator(IndicatorState::Offline);
} else {
setIndicator(IndicatorState::Normal);
}
}
~~~
Call this from the application loop after wifi.process(). For profile mode, add getStationStatus() so the product can distinguish an active station recovery attempt from a portal session.
## Event pattern
Events reduce polling for transitions, but getters remain the authoritative state. Use setEventCallback() to record a transition or wake a product state machine, then read the relevant portal/profile status. Avoid performing long work in the callback; keep it suitable for the normal firmware loop.
## Operator-facing messages
Use getConfigPortalConnectMessage() and getWLStatusString() for concise local diagnostics. Never place Wi-Fi passwords, portal parameter values, or secrets in an operator display or remotely collected logs.
See [Observability](../OBSERVABILITY.md) for the complete event list and [Field installer provisioning](FIELD_INSTALLER_PROVISIONING.md) for timeout policy.
+15
View File
@@ -0,0 +1,15 @@
# Integration recipes
These patterns come from real firmware that consumes this WiFiManager fork. They describe the boundary between a product application and WiFiManager; they are not additional framework APIs and do not require a cloud service or companion app.
| Scenario | Start here when… |
| --- | --- |
| [Field installer provisioning](FIELD_INSTALLER_PROVISIONING.md) | A physical device needs a deliberately bounded setup window on site. |
| [Product settings and Wi-Fi](PRODUCT_SETTINGS_AND_WIFI.md) | The portal collects Wi-Fi and application-owned settings together. |
| [Primary and fallback Wi-Fi](PRIMARY_AND_FALLBACK_WIFI.md) | A deployment supplies a candidate primary/fallback network that must be verified before storage. |
| [Local web-service handoff](LOCAL_WEB_SERVICE_HANDOFF.md) | The application already owns port 80 when recovery provisioning may begin. |
| [Provisioning state feedback](PROVISIONING_STATE_FEEDBACK.md) | LEDs, a display, or logs need to distinguish setup, recovery, and connected states. |
The normal [Basic Portal](../../examples/BasicPortal/) example remains the shortest way to try the legacy saved-network-or-portal flow. These recipes explain the product concerns that a standalone example should not pretend to solve.
Back to [documentation](../README.md) · [project overview](../../README.md).
+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), [getting started](../../docs/GETTING_STARTED.md), and [provisioning lifecycle](../../docs/PROVISIONING_LIFECYCLE.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 and configuration](../../docs/PORTAL_UI.md), [architecture boundaries](../../docs/ARCHITECTURE.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,40 @@
#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);
// WiFiManager copies this read-only status section when it is registered.
PortalInfoSection deviceInfo;
deviceInfo.id = "device";
deviceInfo.title = "Example device";
deviceInfo.items = {
{"firmware", "Firmware", "1.0.0"},
{"sensor", "Sensor", "Ready"},
};
portal.portalAddInfoSection(deviceInfo);
// This callout appears on the built-in portal overview.
PortalHomeCard hint;
hint.id = "hint";
hint.title = "What this example adds";
hint.kind = PortalHomeCardKind::Callout;
hint.text = "A normal text setting, a status section, and a home-page callout.";
portal.portalAddHomeCard(hint);
portal.setSaveParamsCallback([](WiFiManager::WiFiManagerRequestArgs) {
// A product validates and persists this value here; this demo only prints it.
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 content](../../docs/PORTAL_CONTENT.md), [product settings and Wi-Fi](../../docs/recipes/PRODUCT_SETTINGS_AND_WIFI.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
+29
View File
@@ -0,0 +1,29 @@
# 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 matching setup network and open `http://192.168.4.1/`.
| Example | Setup network | Password |
| --- | --- | --- |
| Basic Portal | WiFiManager Basic | example-pass |
| Custom Portal Content | WiFiManager Content | example-pass |
| Branded Portal | Temperature Monitor | none |
| Station Profiles | WiFiManager Profiles | example-pass |
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), [primary and fallback Wi-Fi](../../docs/recipes/PRIMARY_AND_FALLBACK_WIFI.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
+201 -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);
@@ -907,6 +1012,10 @@ protected:
void failAsyncScan(wm_scan_state_t state, int scanResult = WIFI_SCAN_FAILED);
void resetAsyncScan(bool clearResults);
void invalidateScanResults();
// Release cached scan-result vector capacity after a portal fully closes.
// Do not use this during normal refreshes: the cache is intentionally kept
// while the portal is active so the UI can render nearby networks.
void releaseScanResultStorage();
bool hasFreshScanResults(unsigned int cachetime) const;
bool canRunAsyncScan() const;
void cacheScanResults(int networksFound);
@@ -931,12 +1040,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 +1088,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 +1152,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>";
+599 -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;
}
@@ -865,6 +1373,7 @@ bool WiFiManager::shutdownConfigPortal(){
}
resetAsyncScan(true);
releaseScanResultStorage();
if(!configPortalActive) return false;
@@ -1446,6 +1955,15 @@ void WiFiManager::resetAsyncScan(bool clearResults) {
_scan.visibleNetworkCount = _numNetworks;
}
}
void WiFiManager::releaseScanResultStorage() {
// clear() releases String payloads but deliberately retains the vector's
// backing allocation. At portal shutdown that cache has no remaining value,
// so release it with the server/DNS objects rather than carrying it into the
// normal connected runtime. Keep this out of the refresh path to avoid
// needless allocation churn while a user is looking at nearby networks.
std::vector<WiFiScanNetwork>().swap(_scanResultsCache);
}
void WiFiManager::invalidateScanResults() {
_scan.resultsValid = false;
@@ -2095,20 +2613,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 +2699,7 @@ void WiFiManager::portalSetPageSetupVisible(bool visible) {
_portalPages.setupVisible = visible;
}
void WiFiManager::portalSetActionEraseVisible(bool visible) {
_portalActions.eraseVisible = visible;
}
@@ -2227,17 +2804,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
+222 -81
View File
@@ -16,10 +16,6 @@
#include <cstring>
#if defined(ESP8266) || defined(ESP32)
#ifndef WM_PAGE_RESERVE_BYTES
#define WM_PAGE_RESERVE_BYTES 8192
#endif
static void jsonAppendEscaped(String& out, const String& s) {
for (size_t i = 0; i < s.length(); i++) {
const char c = s[i];
@@ -37,9 +33,26 @@ 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 {
inline void reservePage(String& page, size_t extraBytes = WM_PAGE_RESERVE_BYTES) {
const char kEmptyPortalPlaceholder[] PROGMEM = "";
inline void reservePage(String& page, size_t extraBytes) {
if (extraBytes == 0) return;
const size_t targetLen = page.length() + extraBytes;
(void)page.reserve(targetLen);
@@ -60,24 +73,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 +125,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 +161,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 +192,6 @@ void WiFiManagerHandlers::appendPortalExtraInfoSectionsJson(String& json, bool&
json += F("]}");
}
}
void WiFiManagerHandlers::appendPortalExtraHomeCardsJson(String& json, bool& first) {
if (_wm == nullptr) {
return;
@@ -263,21 +255,23 @@ void WiFiManagerHandlers::collectVisibleScanResults(std::vector<const WiFiManage
}
if (_wm->_removeDuplicateAPs) {
std::vector<const WiFiManager::WiFiScanNetwork*> deduped;
deduped.reserve(networks.size());
// Reuse the pointer storage that was already reserved above. Building a
// second vector briefly doubles this request's scan-list allocation on
// constrained ESP8266 heaps.
size_t kept = 0;
for (const auto* network : networks) {
bool duplicate = false;
for (const auto* existing : deduped) {
if (existing->ssid == network->ssid) {
for (size_t i = 0; i < kept; ++i) {
if (networks[i]->ssid == network->ssid) {
duplicate = true;
break;
}
}
if (!duplicate) {
deduped.push_back(network);
networks[kept++] = network;
}
}
networks.swap(deduped);
networks.resize(kept);
}
}
@@ -686,25 +680,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 +776,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 +1062,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 +1089,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 +1241,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 +1330,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 +1402,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);
});
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "WiFiManager",
"version": "3.0.6",
"version": "3.2.3",
"keywords": [
"wifi",
"wi-fi",
@@ -47,18 +47,18 @@
{
"owner": "esp32async",
"name": "ESPAsyncTCP",
"version": "2.0.0",
"version": "^2.0.0",
"platforms": "espressif8266"
},
{
"owner": "esp32async",
"name": "AsyncTCP",
"version": "3.4.9",
"version": "^3.4.9",
"platforms": "espressif32"
},
{
"name": "DeviceFrameworkTemplateEngine",
"version": "https://github.com/alexhopeoconnor/DFTE.git#v1.0.2"
"version": "https://github.com/alexhopeoconnor/DFTE.git#v1.2.1"
}
],
"build": {
@@ -71,8 +71,8 @@
"lib/WiFiManager/src",
"LICENSE",
"README.md",
"library.json",
"tools"
"CHANGELOG.md",
"library.json"
]
},
"$schema": "https://raw.githubusercontent.com/platformio/platformio-core/develop/platformio/assets/schema/library.json",
+6 -4
View File
@@ -16,11 +16,11 @@ build_flags =
-DUNIT_TEST
lib_deps =
ESP32Async/ESPAsyncWebServer@3.9.1
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.0.2
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.0
ESP32Async/ESPAsyncTCP@2.0.0
[env:esp32]
platform = espressif32@6.13.0
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
board = esp32dev
framework = arduino
monitor_speed = 115200
@@ -30,12 +30,14 @@ build_unflags =
-std=gnu++11
build_flags =
-std=gnu++14
-DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
-DWM_LOG_LEVEL=5
-DUNIT_TEST
lib_deps =
ESP32Async/ESPAsyncWebServer@3.9.1
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.0.2
ESP32Async/AsyncTCP@3.4.9
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.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."
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 --port /dev/serial/by-id/... [--timeout seconds]" >&2
exit 2
}
port=""
timeout_seconds=300
while [[ $# -gt 0 ]]; do
case "$1" in
--port) [[ $# -ge 2 ]] || usage; port="${2:-}"; shift 2 ;;
--timeout) [[ $# -ge 2 ]] || usage; timeout_seconds="${2:-}"; shift 2 ;;
*) usage ;;
esac
done
[[ -n "$port" && -e "$port" ]] || usage
[[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || usage
capture_file="$(mktemp -p /tmp wifimanager-unity.XXXXXX)"
monitor_pid=""
preserve_capture=false
cleanup() {
if [[ -n "$monitor_pid" ]] && kill -0 "$monitor_pid" 2>/dev/null; then
kill "$monitor_pid" 2>/dev/null || true
wait "$monitor_pid" 2>/dev/null || true
fi
if [[ "$preserve_capture" == "false" ]]; then
rm -f "$capture_file"
fi
}
trap cleanup EXIT
# Start immediately after upload. PlatformIO's interactive monitor cannot run
# without a TTY; socat opens only this port and streams its configured 115200
# baud output into the capture file without touching another board.
timeout --foreground "$timeout_seconds" socat -u "FILE:$port,raw,echo=0,b115200" STDOUT \
>"$capture_file" 2>&1 &
monitor_pid="$!"
while kill -0 "$monitor_pid" 2>/dev/null; do
if grep -aqE '[0-9]+ Tests [0-9]+ Failures' "$capture_file"; then
kill "$monitor_pid" 2>/dev/null || true
wait "$monitor_pid" 2>/dev/null || true
monitor_pid=""
if grep -aq "Tests 0 Failures" "$capture_file" && grep -aq "^OK" "$capture_file"; then
grep -aE '\[METRIC\]|Tests [0-9]+ Failures|^OK$' "$capture_file" || true
exit 0
fi
echo "Unity reported a test failure:" >&2
grep -anE ':FAIL|FAIL$|\[METRIC\]|Tests [0-9]+ Failures' "$capture_file" >&2 || true
tail -n 80 "$capture_file" >&2 || true
preserve_capture=true
echo "Full serial capture retained at $capture_file" >&2
exit 1
fi
sleep 0.25
done
wait "$monitor_pid" || true
monitor_pid=""
preserve_capture=true
echo "Serial monitoring ended before Unity produced a summary; capture retained at $capture_file" >&2
exit 1
+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"
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 --platform esp8266|esp32" >&2
exit 2
}
[[ "${1:-}" == "--platform" ]] || usage
platform="${2:-}"
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
[[ $# -eq 2 ]] || usage
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
pio run -d "$root/test/compile-project" -e "$platform"
echo "WiFiManager consumer compile check passed for $platform"
+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"
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE' >&2
Usage:
./scripts/test.sh compile --platform esp8266|esp32
./scripts/test.sh examples --platform esp8266|esp32
./scripts/test.sh hardware --platform esp8266|esp32 --port /dev/serial/by-id/...
USAGE
exit 2
}
mode="${1:-}"
[[ "$mode" == "compile" || "$mode" == "examples" || "$mode" == "hardware" ]] || usage
shift
platform=""
port=""
while [[ $# -gt 0 ]]; do
case "$1" in
--platform) [[ $# -ge 2 ]] || usage; platform="${2:-}"; shift 2 ;;
--port) [[ $# -ge 2 ]] || usage; port="${2:-}"; shift 2 ;;
*) usage ;;
esac
done
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
[[ "$mode" != "hardware" || -n "$port" ]] || usage
[[ "$mode" != "hardware" || -e "$port" ]] || { echo "Serial port not found: $port" >&2; exit 1; }
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ "$mode" == "hardware" ]]; then
# Keep serial flashing and portal-adapter work mutually exclusive.
# shellcheck source=tools/lib/portal-hardware-session.sh
source "$root/tools/lib/portal-hardware-session.sh"
wm_acquire_hardware_lock
fi
if [[ "$mode" == "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
if [[ "$mode" == "hardware" ]]; then
# Upload first, then capture from the normal boot reset. The Unity sketch
# deliberately waits two seconds before it begins its test sequence.
pio test -d "$root" -e "$platform" --filter test_wifimanager \
--upload-port "$port" --without-testing
"$root/scripts/capture-unity-serial.sh" --port "$port" --timeout 300
echo "WiFiManager hardware test passed for $platform on $port"
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"
+3 -1
View File
@@ -20,11 +20,13 @@ lib_deps =
[env:esp32]
extends = common
platform = espressif32@6.13.0
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
-std=gnu++14
-DSOC_WIFI_SUPPORTED=1
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
-DWM_LOG_LEVEL=3
lib_deps =
${common.lib_deps}
+36
View File
@@ -1,9 +1,45 @@
#include <Arduino.h>
#include <WiFiManager.h>
namespace {
const char kTitle[] PROGMEM = "Set up Compile Fixture";
const char kIdentity[] PROGMEM = "WiFiManager";
const char 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);
}
+16
View File
@@ -0,0 +1,16 @@
# Portal browser harness
This test fixture starts only the WiFiManager captive portal. It intentionally has no station credentials, MQTT configuration, DeviceFramework dependency, or application logic.
Use it through the repository runner so a secondary Wi-Fi adapter is explicitly selected and protected from becoming the host default route:
```bash
./tools/portal-hardware run \
--platform esp8266 \
--port /dev/serial/by-id/... \
--client-interface wlx...
```
The ESP8266 portal SSID is `WM Contract ESP8266`; the ESP32 SSID is `WM Contract ESP32`. Both use `default1` exclusively for local development tests.
The runner cleans up only the temporary connection it creates on the named secondary interface. It refuses to run if that interface is the system default route.
+28
View File
@@ -0,0 +1,28 @@
[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=4
[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=4
+36
View File
@@ -0,0 +1,36 @@
#include <Arduino.h>
#include <WiFiManager.h>
namespace {
#if defined(ESP8266)
constexpr char kPortalSsid[] = "WM Contract ESP8266";
#else
constexpr char kPortalSsid[] = "WM Contract ESP32";
#endif
constexpr char kPortalPassword[] = "default1";
WiFiManager wifi;
WiFiManagerParameter kInstallationLabel(
"installation_label", "Installation label", "Contract fixture", 32);
} // namespace
void setup() {
Serial.begin(115200);
delay(300);
// The fixture intentionally has no station credentials. A finite window
// exercises timeout reset without leaving a board in a permanent portal.
wifi.setConfigPortalTimeout(15 * 60);
wifi.setAPStaticIPConfig(
IPAddress(192, 168, 4, 1),
IPAddress(192, 168, 4, 1),
IPAddress(255, 255, 255, 0));
wifi.portalAddParameter(&kInstallationLabel);
wifi.startConfigPortal(kPortalSsid, kPortalPassword);
}
void loop() {
wifi.process();
}
+4
View File
@@ -0,0 +1,4 @@
# Ignored local credentials for the optional station handoff test.
# These values are mounted read-only into the Docker test container.
WIFI_SSID=replace-me
WIFI_PASSWORD=replace-me
+8 -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),
@@ -102,9 +105,10 @@ TestCase tests[] = {
TEST_ENTRY(test_scan_cancels_when_lifecycle_blocked),
TEST_ENTRY(test_scan_generation_invalidated_on_reset),
TEST_ENTRY(test_real_async_scan_completes),
// 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),
@@ -125,6 +129,8 @@ TestCase tests[] = {
TEST_ENTRY(test_rapid_portal_start_stop),
TEST_ENTRY(test_multiple_parameters_stress),
TEST_ENTRY(test_portal_with_timeout_stress),
TEST_ENTRY(test_portal_resource_recovery),
TEST_ENTRY(test_scan_result_storage_released_when_portal_closes),
};
const size_t TEST_COUNT = sizeof(tests) / sizeof(TestCase);
+8 -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();
@@ -105,16 +107,18 @@ void test_scan_completion_wait();
void test_scan_cancels_when_connect_pending();
void test_scan_cancels_when_lifecycle_blocked();
void test_scan_generation_invalidated_on_reset();
void test_real_async_scan_completes();
// 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();
@@ -138,6 +142,8 @@ void test_long_running_portal();
void test_rapid_portal_start_stop();
void test_multiple_parameters_stress();
void test_portal_with_timeout_stress();
void test_portal_resource_recovery();
void test_scan_result_storage_released_when_portal_closes();
#endif // TEST_MAIN_H
@@ -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);
@@ -4,7 +4,8 @@
// Test process() doesn't block - verify it must be called periodically
void test_nonblocking_process() {
Serial.println("[TEST] Testing process() calls (non-blocking behavior)...");
constexpr unsigned long kMaximumProcessDurationMs = 250;
unsigned long maximumObservedDurationMs = 0;
WiFiManager wm;
@@ -18,10 +19,13 @@ void test_nonblocking_process() {
for (int i = 0; i < 10; i++) {
unsigned long start = millis();
wm.process();
unsigned long elapsed = millis() - start;
const unsigned long elapsed = millis() - start;
maximumObservedDurationMs = max(maximumObservedDurationMs, elapsed);
// Each call should be fast (< 100ms) - verifies non-blocking behavior
TEST_ASSERT_LESS_THAN(100, elapsed);
// A normal cooperative call is quick, but DNS and Wi-Fi service may
// briefly run on ESP8266. This still catches a truly blocking portal.
TEST_ASSERT_LESS_THAN_MESSAGE(kMaximumProcessDurationMs, elapsed,
"process() exceeded its bounded service time");
delay(10);
}
@@ -31,6 +35,7 @@ void test_nonblocking_process() {
wm.stopConfigPortal();
Serial.printf("[METRIC] WM_PROCESS max_elapsed_ms=%lu\n", maximumObservedDurationMs);
Serial.println("[TEST] Non-blocking process() test completed successfully");
}
@@ -0,0 +1,186 @@
#include <Arduino.h>
#include <WiFiManager.h>
#include <unity.h>
#include <vector>
#include "../test_main.h"
namespace {
struct HeapSnapshot {
uint32_t freeHeap;
uint32_t largestBlock;
uint8_t fragmentation;
};
HeapSnapshot captureHeap(const char* label) {
HeapSnapshot snapshot{
ESP.getFreeHeap(),
#if defined(ESP8266)
ESP.getMaxFreeBlockSize(),
ESP.getHeapFragmentation(),
#else
ESP.getMaxAllocHeap(),
0,
#endif
};
Serial.printf("[METRIC] WM_HEAP label=%s free=%lu largest=%lu fragmentation=%u\n",
label,
static_cast<unsigned long>(snapshot.freeHeap),
static_cast<unsigned long>(snapshot.largestBlock),
snapshot.fragmentation);
return snapshot;
}
uint32_t allowedHeapDrift() {
#if defined(ESP8266)
return 1024;
#else
return 4096;
#endif
}
uint32_t allowedLargestBlockDrift() {
#if defined(ESP8266)
return 1024;
#else
return 4096;
#endif
}
void assertRecovered(const HeapSnapshot& settled, const HeapSnapshot& final) {
const uint32_t freeFloor = settled.freeHeap > allowedHeapDrift()
? settled.freeHeap - allowedHeapDrift()
: 0;
const uint32_t blockFloor = settled.largestBlock > allowedLargestBlockDrift()
? settled.largestBlock - allowedLargestBlockDrift()
: 0;
TEST_ASSERT_GREATER_OR_EQUAL_UINT32_MESSAGE(
freeFloor, final.freeHeap,
"Portal start/stop cycles retained too much heap after warm-up");
TEST_ASSERT_GREATER_OR_EQUAL_UINT32_MESSAGE(
blockFloor, final.largestBlock,
"Portal start/stop cycles degraded the largest contiguous allocation");
}
void startAndStopPortal(WiFiManager& wm, const char* ssid) {
wm.startConfigPortal(ssid);
wm.process();
TEST_ASSERT_TRUE_MESSAGE(wm.getConfigPortalActive(), "Portal should start");
TEST_ASSERT_NOT_NULL_MESSAGE(wm.getServer(), "Portal server should exist");
TEST_ASSERT_NOT_NULL_MESSAGE(wm.getDNSServer(), "Portal DNS server should exist");
wm.stopConfigPortal();
wm.process();
TEST_ASSERT_FALSE_MESSAGE(wm.getConfigPortalActive(), "Portal should stop");
TEST_ASSERT_NULL_MESSAGE(wm.getServer(), "Portal server should be released");
TEST_ASSERT_NULL_MESSAGE(wm.getDNSServer(), "Portal DNS server should be released");
}
} // namespace
void test_portal_resource_recovery() {
Serial.println("[TEST] Measuring portal resource recovery...");
WiFiManager wm;
wm.setConfigPortalTimeout(20);
// Warm the Wi-Fi core once before establishing the comparison point. The
// SDK is allowed to retain its own one-time allocations; the test detects
// repeated decline after that settled point instead.
startAndStopPortal(wm, "WM-Memory-Warmup");
delay(200);
const HeapSnapshot settled = captureHeap("settled");
for (uint8_t cycle = 0; cycle < 20; ++cycle) {
const String ssid = String("WM-Memory-") + String(cycle);
wm.startConfigPortal(ssid.c_str());
wm.process();
TEST_ASSERT_TRUE_MESSAGE(wm.getConfigPortalActive(), "Portal should start during cycle");
TEST_ASSERT_NOT_NULL_MESSAGE(wm.getServer(), "Server should exist during cycle");
TEST_ASSERT_NOT_NULL_MESSAGE(wm.getDNSServer(), "DNS should exist during cycle");
if (cycle == 0) {
captureHeap("portal-active");
}
wm.stopConfigPortal();
wm.process();
TEST_ASSERT_FALSE_MESSAGE(wm.getConfigPortalActive(), "Portal should stop during cycle");
TEST_ASSERT_NULL_MESSAGE(wm.getServer(), "Server should be released during cycle");
TEST_ASSERT_NULL_MESSAGE(wm.getDNSServer(), "DNS should be released during cycle");
captureHeap("cycle-stopped");
}
delay(200);
const HeapSnapshot final = captureHeap("final");
assertRecovered(settled, final);
Serial.println("[TEST] Portal resource recovery test completed successfully");
}
void test_scan_result_storage_released_when_portal_closes() {
Serial.println("[TEST] Testing scan-result storage release on portal close...");
WiFiManager wm;
wm.setConfigPortalTimeout(20);
wm.startConfigPortal("WM-Scan-Storage");
wm.process();
#ifdef UNIT_TEST
std::vector<WiFiManager::WiFiScanNetwork> results;
for (uint8_t i = 0; i < 24; ++i) {
results.push_back({String("Network-") + String(i), -30 - i, static_cast<uint8_t>(i % 2)});
}
wm.wmTestInjectScanResults(results);
captureHeap("scan-cache-held");
TEST_ASSERT_GREATER_THAN_UINT32(0, wm.getScanResults().capacity());
#endif
wm.stopConfigPortal();
wm.process();
TEST_ASSERT_EQUAL_UINT32(0, wm.getScanResults().size());
TEST_ASSERT_EQUAL_UINT32(0, wm.getScanResults().capacity());
captureHeap("scan-storage-cleared");
Serial.println("[TEST] Scan-result storage release test completed successfully");
}
void test_real_async_scan_completes() {
Serial.println("[TEST] Testing real asynchronous Wi-Fi scan completion...");
WiFiManager wm;
wm.setConfigPortalTimeout(30);
wm.startConfigPortal("WM-Real-Scan");
wm.process();
TEST_ASSERT_TRUE_MESSAGE(wm.getConfigPortalActive(), "Portal should be active for scanning");
captureHeap("scan-portal-active");
wm.requestAsyncScan(true);
captureHeap("scan-requested");
const uint32_t deadline = millis() + 25000UL;
while (wm.isScanRunning() && millis() < deadline) {
wm.process();
delay(20);
}
const WiFiManager::WiFiScanRuntimeState scan = wm.getScanSnapshot();
Serial.printf("[METRIC] WM_SCAN state=%u result=%d count=%d elapsed=%lu\n",
static_cast<unsigned>(scan.state),
scan.lastScanResult,
static_cast<int>(wm.getScanResults().size()),
static_cast<unsigned long>(millis() - scan.startedAt));
captureHeap("scan-complete-cache");
const bool stillRunning = wm.isScanRunning();
const WiFiManager::wm_scan_state_t state = wm.getScanState();
wm.stopConfigPortal();
captureHeap("scan-storage-cleared");
TEST_ASSERT_FALSE_MESSAGE(stillRunning, "Async scan exceeded its completion deadline");
TEST_ASSERT_EQUAL_MESSAGE(WiFiManager::WM_SCAN_COMPLETE, state,
"Real scan must complete rather than enter failed/timeout state");
Serial.println("[TEST] Real asynchronous Wi-Fi scan test completed successfully");
}
@@ -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");
}
+11
View File
@@ -0,0 +1,11 @@
ARG PLAYWRIGHT_VERSION=1.63.0
FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble
ARG PLAYWRIGHT_VERSION
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
WORKDIR /work
COPY tests/portal-contract/package.json tests/portal-contract/package-lock.json ./
RUN npm ci --ignore-scripts && \
[ "$(node -p "require('@playwright/test/package.json').version")" = "$PLAYWRIGHT_VERSION" ]
COPY tests/portal-contract/ ./
CMD ["npm", "test"]
+11
View File
@@ -0,0 +1,11 @@
# Portal contract container
This directory contains the browser/API half of the real-hardware portal test.
Run it through [`../../tools/portal-hardware`](../../tools/portal-hardware), not
directly: the host command alone selects the serial board and attaches the
explicit secondary Wi-Fi adapter. Docker uses the host network only to reach
the already-routed `192.168.4.1` portal; it never manages host Wi-Fi.
The image pins the Playwright package to the matching official browser image.
Artifacts, traces, screenshots, JSON results, and the HTML report are written
to the output directory printed by the host command.
@@ -0,0 +1,6 @@
services:
portal-contract:
environment:
PORTAL_STATION_ENV: /run/secrets/portal-station.env
volumes:
- ${PORTAL_STATION_ENV_HOST:?station environment path is required}:/run/secrets/portal-station.env:ro
+18
View File
@@ -0,0 +1,18 @@
services:
portal-contract:
build:
context: ../..
dockerfile: tests/portal-contract/Dockerfile
args:
PLAYWRIGHT_VERSION: "1.63.0"
network_mode: host
ipc: host
init: true
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
environment:
HOME: /tmp
PORTAL_URL: http://192.168.4.1
PORTAL_BROWSER_MODE: ${PORTAL_BROWSER_MODE:-auto}
ARTIFACT_DIR: /artifacts
volumes:
- ${PORTAL_ARTIFACT_DIR:?portal artifact directory is required}:/artifacts
+60
View File
@@ -0,0 +1,60 @@
{
"name": "wifimanager-portal-contract",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wifimanager-portal-contract",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "1.63.0"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "wifimanager-portal-contract",
"private": true,
"version": "1.0.0",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.63.0"
}
}
@@ -0,0 +1,25 @@
const path = require('path');
const { defineConfig } = require('@playwright/test');
const artifactDir = process.env.ARTIFACT_DIR || path.join(__dirname, 'artifacts');
module.exports = defineConfig({
testDir: './tests',
timeout: 45_000,
expect: { timeout: 10_000 },
forbidOnly: !!process.env.CI,
fullyParallel: false,
workers: 1,
outputDir: path.join(artifactDir, 'test-results'),
reporter: [
['list'],
['json', { outputFile: path.join(artifactDir, 'report.json') }],
['html', { outputFolder: path.join(artifactDir, 'html-report'), open: 'never' }],
],
use: {
baseURL: process.env.PORTAL_URL || 'http://192.168.4.1',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
});
@@ -0,0 +1,111 @@
const { test, expect } = require('@playwright/test');
async function json(response) {
return JSON.parse(await response.text());
}
async function waitForScan(request) {
let result;
await expect.poll(async () => {
try {
const response = await request.get('/api/wifi/scan-status');
if (!response.ok()) return true;
result = await json(response);
return result.scanning;
} catch {
// ESP8266 AP+STA scans briefly leave the AP channel. A client can lose
// its association while the radio scans, then reconnect before the
// asynchronous scan completes. Keep polling; the assertions below still
// require a reachable portal with a complete, valid result.
return true;
}
}, { timeout: 45_000, intervals: [500, 800, 1_000] }).toBe(false);
return result;
}
test.describe('portal AP contract', () => {
test('serves API, persists fixture parameters, and completes a real scan', async ({ request }) => {
const root = await request.get('/');
expect(root.ok()).toBeTruthy();
expect(await root.text()).toContain('<html');
const [bootstrapResponse, concurrentRoot] = await Promise.all([
request.get('/api/bootstrap'),
request.get('/'),
]);
expect(concurrentRoot.ok()).toBeTruthy();
const bootstrap = await json(bootstrapResponse);
expect(bootstrap.contractVersion).toBe(3);
expect(bootstrap.context.portalActive).toBe(true);
const metaResponse = await request.get('/api/wifi/meta');
expect(metaResponse.ok()).toBeTruthy();
const meta = await json(metaResponse);
expect(JSON.stringify(meta)).toContain('installation_label');
const infoResponse = await request.get('/api/info');
expect(infoResponse.ok()).toBeTruthy();
expect(JSON.stringify(await json(infoResponse))).toContain('192.168.4.1');
const statusResponse = await request.get('/api/status');
expect(statusResponse.ok()).toBeTruthy();
const saveResponse = await request.post('/api/params/save', {
form: { installation_label: 'Contract verified' },
});
expect(saveResponse.ok()).toBeTruthy();
expect(JSON.stringify(await json(saveResponse))).toContain('saved');
const paramsResponse = await request.get('/api/params');
expect(paramsResponse.ok()).toBeTruthy();
expect(JSON.stringify(await json(paramsResponse))).toContain('Contract verified');
const resetResponse = await request.post('/api/portal/timeout-reset');
expect(resetResponse.ok()).toBeTruthy();
expect((await json(resetResponse)).timeoutSecondsRemaining).toBeGreaterThan(800);
const scanStart = await request.post('/api/wifi/scan');
expect([200, 202, 409]).toContain(scanStart.status());
const completed = await waitForScan(request);
expect(completed.state).toBe('complete');
expect(completed.results_valid).toBe(true);
const missing = await request.get('/not-a-portal-route');
expect(missing.status()).toBe(404);
});
test('renders stable desktop and mobile portal views without page errors', async ({ browser }) => {
test.skip(process.env.PORTAL_BROWSER_MODE === 'skip', 'Browser checks were explicitly skipped.');
const desktop = await browser.newContext({ viewport: { width: 1440, height: 1080 } });
const page = await desktop.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await page.goto('/', { waitUntil: 'networkidle' });
await expect(page.locator('#wm-reset-portal-timeout')).toBeVisible();
await page.screenshot({ path: `${process.env.ARTIFACT_DIR}/portal-overview-desktop.png`, fullPage: true });
await page.goto('/#/wifi', { waitUntil: 'networkidle' });
await expect(page.locator('#wm-refresh-scan')).toBeVisible();
await expect(page.locator('#wm-f-installation_label')).toBeVisible();
await page.locator('#wm-f-installation_label').fill('Browser verified');
await page.screenshot({ path: `${process.env.ARTIFACT_DIR}/portal-wifi-desktop.png`, fullPage: true });
const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true });
const mobilePage = await mobile.newPage();
mobilePage.on('pageerror', (error) => errors.push(error.message));
mobilePage.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text());
});
await mobilePage.goto('/#/info', { waitUntil: 'networkidle' });
await expect(mobilePage.locator('.wm-page-head')).toBeVisible();
await mobilePage.screenshot({ path: `${process.env.ARTIFACT_DIR}/portal-device-mobile.png`, fullPage: true });
await mobile.close();
await desktop.close();
expect(errors).toEqual([]);
});
});
@@ -0,0 +1,43 @@
const fs = require('fs');
const { test, expect } = require('@playwright/test');
function stationCredentials() {
const file = process.env.PORTAL_STATION_ENV;
if (!file) return null;
const values = {};
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
if (!raw || raw.startsWith('#')) continue;
const separator = raw.indexOf('=');
if (separator > 0) values[raw.slice(0, separator)] = raw.slice(separator + 1);
}
if (!values.WIFI_SSID || !values.WIFI_PASSWORD) {
throw new Error('PORTAL_STATION_ENV must define WIFI_SSID and WIFI_PASSWORD.');
}
return values;
}
test('optionally hands the fixture off to a real station network', async ({ request }) => {
const credentials = stationCredentials();
test.skip(!credentials, 'No station environment was supplied.');
const metaResponse = await request.get('/api/wifi/meta');
expect(metaResponse.ok()).toBeTruthy();
const meta = JSON.parse(await metaResponse.text());
const form = Array.isArray(meta.profiles) && meta.profiles.length
? { s0: credentials.WIFI_SSID, p0: credentials.WIFI_PASSWORD, stationAction: 'connect' }
: { s: credentials.WIFI_SSID, p: credentials.WIFI_PASSWORD, stationAction: 'connect' };
const queued = await request.post('/api/wifi/save', { form });
expect(queued.status()).toBe(202);
let state;
await expect.poll(async () => {
const response = await request.get('/api/wifi/connect-status');
state = JSON.parse(await response.text());
return state.state;
}, { timeout: 45_000, intervals: [500, 700, 1_000] }).toBe('success');
expect(state.stationIp).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
expect(state.redirectUrl).toContain(state.stationIp);
const complete = await request.post('/api/wifi/connect-complete');
expect(complete.ok()).toBeTruthy();
});
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
# Shared host-side helpers for the WiFiManager portal hardware contract.
# They never modify a network interface other than the explicit client adapter.
wm_portal_state_root() {
printf '%s/wifimanager-portal-hardware' "${XDG_STATE_HOME:-$HOME/.local/state}"
}
wm_require() {
command -v "$1" >/dev/null 2>&1 || {
echo "Required command not found: $1" >&2
return 1
}
}
wm_default_route_interface() {
ip route show default 2>/dev/null | awk '/^default/{print $5; exit}'
}
wm_acquire_hardware_lock() {
local lock_file="${WM_HARDWARE_LOCK_FILE:-/tmp/wifimanager-hardware.lock}"
exec 9>"$lock_file"
flock -n 9 || {
echo "Another WiFiManager hardware task is already running; wait for it to finish." >&2
return 1
}
}
wm_require_client_adapter() {
local interface="$1" allow_takeover="$2" default_interface active_connection
ip link show "$interface" >/dev/null 2>&1 || {
echo "Wi-Fi interface not found: $interface" >&2
return 1
}
default_interface="$(wm_default_route_interface)"
[[ "$interface" != "$default_interface" ]] || {
echo "Refusing to use the host default-route interface: $interface" >&2
return 1
}
active_connection="$(nmcli -g GENERAL.CONNECTION device show "$interface" 2>/dev/null || true)"
if [[ -n "$active_connection" && "$active_connection" != "--" && "$allow_takeover" != "yes" ]]; then
echo "Client adapter $interface already has connection '$active_connection'." >&2
echo "Pass --take-over-client-adapter to replace only that adapter's connection." >&2
return 1
fi
}
wm_portal_ssid() {
case "$1" in
esp8266) printf '%s\n' 'WM Contract ESP8266' ;;
esp32) printf '%s\n' 'WM Contract ESP32' ;;
*) return 1 ;;
esac
}
wm_wait_for_portal_ssid() {
local interface="$1" ssid="$2" attempt
nmcli device wifi rescan ifname "$interface" >/dev/null 2>&1 || true
for attempt in $(seq 1 45); do
if nmcli -t -f SSID device wifi list ifname "$interface" | grep -Fxq "$ssid"; then
return 0
fi
sleep 1
nmcli device wifi rescan ifname "$interface" >/dev/null 2>&1 || true
done
echo "Portal SSID not detected on $interface: $ssid" >&2
return 1
}
wm_remove_connection_by_name() {
local name="$1"
[[ -n "$name" ]] || return 0
nmcli connection down "$name" >/dev/null 2>&1 || true
nmcli connection delete "$name" >/dev/null 2>&1 || true
}
wm_create_portal_connection() {
local interface="$1" ssid="$2" password="$3" name uuid
name="wifimanager-portal-${RANDOM}-$(date +%s)"
nmcli device disconnect "$interface" >/dev/null 2>&1 || true
wm_wait_for_portal_ssid "$interface" "$ssid"
if ! nmcli connection add type wifi ifname "$interface" con-name "$name" ssid "$ssid" \
ipv4.method auto ipv4.never-default yes ipv6.method ignore connection.autoconnect no >/dev/null; then
return 1
fi
if ! nmcli connection modify "$name" wifi-sec.key-mgmt wpa-psk wifi-sec.psk "$password"; then
wm_remove_connection_by_name "$name"
return 1
fi
if ! nmcli connection up "$name" ifname "$interface"; then
wm_remove_connection_by_name "$name"
return 1
fi
uuid="$(nmcli -g connection.uuid connection show "$name")"
if [[ -z "$uuid" || "$uuid" == "--" ]]; then
wm_remove_connection_by_name "$name"
echo "NetworkManager did not return a UUID for the portal connection." >&2
return 1
fi
WM_PORTAL_CONNECTION_UUID="$uuid"
WM_PORTAL_CONNECTION_NAME="$name"
export WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
}
wm_verify_portal_route() {
local interface="$1" route
route="$(ip route get 192.168.4.1 2>/dev/null || true)"
[[ "$route" == *" dev $interface "* ]] || {
echo "Portal route does not use the selected adapter: $route" >&2
return 1
}
}
wm_remove_connection() {
local uuid="$1"
[[ -n "$uuid" ]] || return 0
nmcli connection down uuid "$uuid" >/dev/null 2>&1 || true
nmcli connection delete uuid "$uuid" >/dev/null 2>&1 || true
}
wm_state_file() {
printf '%s/session.env\n' "$(wm_portal_state_root)"
}
wm_require_no_active_session() {
local file
file="$(wm_state_file)"
[[ ! -e "$file" ]] || {
echo "An existing WiFiManager portal session is recorded; run ./tools/portal-hardware down first." >&2
return 1
}
}
wm_write_state() {
local interface="$1" platform="$2" uuid="$3" name="$4" root file
root="$(wm_portal_state_root)"
file="$(wm_state_file)"
install -d -m 700 "$root"
(
umask 077
printf 'WM_PORTAL_INTERFACE=%s\nWM_PORTAL_PLATFORM=%s\nWM_PORTAL_CONNECTION_UUID=%s\nWM_PORTAL_CONNECTION_NAME=%s\n' \
"$interface" "$platform" "$uuid" "$name" >"$file"
)
chmod 600 "$file"
}
wm_load_state() {
local file key value
file="$(wm_state_file)"
[[ -f "$file" ]] || {
echo "No active WiFiManager portal session was found." >&2
return 1
}
WM_PORTAL_INTERFACE=""
WM_PORTAL_PLATFORM=""
WM_PORTAL_CONNECTION_UUID=""
WM_PORTAL_CONNECTION_NAME=""
while IFS='=' read -r key value; do
case "$key" in
WM_PORTAL_INTERFACE|WM_PORTAL_PLATFORM|WM_PORTAL_CONNECTION_UUID|WM_PORTAL_CONNECTION_NAME)
printf -v "$key" '%s' "$value"
;;
'') ;;
*)
echo "Invalid WiFiManager portal session state." >&2
return 1
;;
esac
done <"$file"
[[ -n "$WM_PORTAL_INTERFACE" && -n "$WM_PORTAL_PLATFORM" && -n "$WM_PORTAL_CONNECTION_UUID" && -n "$WM_PORTAL_CONNECTION_NAME" ]] || {
echo "Incomplete WiFiManager portal session state." >&2
return 1
}
export WM_PORTAL_INTERFACE WM_PORTAL_PLATFORM WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
}
wm_clear_state() {
local file
file="$(wm_state_file)"
rm -f "$file"
}
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=tools/lib/portal-hardware-session.sh
source "$root/tools/lib/portal-hardware-session.sh"
usage() {
cat <<'USAGE' >&2
Usage:
./tools/portal-hardware doctor --client-interface IFACE [--take-over-client-adapter]
./tools/portal-hardware up --platform esp8266|esp32 --port /dev/serial/by-id/... \
--client-interface IFACE [--take-over-client-adapter] [--output DIRECTORY]
./tools/portal-hardware run --platform esp8266|esp32 --port /dev/serial/by-id/... \
--client-interface IFACE [--take-over-client-adapter] [--keep] \
[--browser auto|skip] [--station-env PATH] [--output DIRECTORY]
./tools/portal-hardware down
Only the named client interface may be disconnected or reconfigured. The tool
refuses the host default-route interface and preserves the created connection
in a 0600 state file until `down` or normal `run` cleanup.
USAGE
exit 2
}
command_name="${1:-}"
[[ -n "$command_name" ]] || usage
shift || true
platform=""
port=""
client_interface=""
takeover="no"
keep="no"
browser="auto"
station_env=""
output_dir=""
while [[ $# -gt 0 ]]; do
case "$1" in
--platform) [[ $# -ge 2 ]] || usage; platform="$2"; shift 2 ;;
--port) [[ $# -ge 2 ]] || usage; port="$2"; shift 2 ;;
--client-interface) [[ $# -ge 2 ]] || usage; client_interface="$2"; shift 2 ;;
--take-over-client-adapter) takeover="yes"; shift ;;
--keep) keep="yes"; shift ;;
--browser) [[ $# -ge 2 ]] || usage; browser="$2"; shift 2 ;;
--station-env) [[ $# -ge 2 ]] || usage; station_env="$2"; shift 2 ;;
--output) [[ $# -ge 2 ]] || usage; output_dir="$2"; shift 2 ;;
*) usage ;;
esac
done
require_common() {
wm_require ip
wm_require nmcli
wm_require pio
wm_require docker
docker compose version >/dev/null
}
prepare_output_dir() {
if [[ -z "$output_dir" ]]; then
output_dir="$(wm_portal_state_root)/runs/$(date -u +%Y%m%dT%H%M%SZ)-$platform"
fi
install -d -m 700 "$output_dir"
output_dir="$(cd "$output_dir" && pwd)"
}
validate_run_arguments() {
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
[[ -n "$client_interface" ]] || usage
[[ -n "$port" && -e "$port" ]] || {
echo "Serial port not found: $port" >&2
exit 1
}
[[ "$browser" == "auto" || "$browser" == "skip" ]] || usage
[[ -z "$station_env" || -r "$station_env" ]] || {
echo "Station environment file is not readable: $station_env" >&2
exit 1
}
}
start_portal_session() {
local ssid
validate_run_arguments
require_common
wm_acquire_hardware_lock
wm_require_no_active_session
wm_require_client_adapter "$client_interface" "$takeover"
prepare_output_dir
ssid="$(wm_portal_ssid "$platform")"
pio run -d "$root/test/portal-harness" -e "$platform" -t upload --upload-port "$port"
if ! wm_create_portal_connection "$client_interface" "$ssid" "default1"; then
return 1
fi
if ! wm_verify_portal_route "$client_interface"; then
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
return 1
fi
if ! wm_write_state "$client_interface" "$platform" "$WM_PORTAL_CONNECTION_UUID" "$WM_PORTAL_CONNECTION_NAME"; then
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
return 1
fi
printf 'Portal connected on %s. Artifacts: %s\n' "$client_interface" "$output_dir"
}
finish_portal_session() {
wm_load_state
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
wm_clear_state
}
case "$command_name" in
doctor)
[[ -n "$client_interface" ]] || usage
require_common
wm_acquire_hardware_lock
wm_require_client_adapter "$client_interface" "$takeover"
printf 'Portal hardware prerequisites are ready. Main route is untouched; client adapter: %s\n' "$client_interface"
;;
up)
start_portal_session
;;
down)
[[ -z "$platform$port$client_interface$station_env$output_dir" ]] || usage
wm_acquire_hardware_lock
finish_portal_session
echo 'Portal client connection removed.'
;;
run)
start_portal_session
cleanup() {
if [[ "$keep" != "yes" ]]; then
finish_portal_session || true
fi
}
trap cleanup EXIT INT TERM
export PORTAL_ARTIFACT_DIR="$output_dir"
export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)"
export PORTAL_BROWSER_MODE="$browser"
compose_files=(-f "$root/tests/portal-contract/compose.yaml")
if [[ -n "$station_env" ]]; then
export PORTAL_STATION_ENV_HOST="$(cd "$(dirname "$station_env")" && pwd)/$(basename "$station_env")"
compose_files+=(-f "$root/tests/portal-contract/compose.station.yaml")
fi
# The contract source is copied into the image; rebuild with Docker cache so
# this invocation always tests the checked-out files, not a stale image.
docker compose "${compose_files[@]}" build portal-contract
docker compose "${compose_files[@]}" run --rm portal-contract
printf 'Portal contract passed. Artifacts: %s\n' "$output_dir"
if [[ "$keep" == "yes" ]]; then
printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n'
fi
;;
*) usage ;;
esac
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
tmp="$(mktemp -d "${TMPDIR:-/tmp}/wifimanager-portal-cli.XXXXXX")"
cleanup() { rm -rf "$tmp"; }
trap cleanup EXIT
stub_bin="$tmp/bin"
mkdir -p "$stub_bin"
export CALL_LOG="$tmp/calls.log"
export WM_HARDWARE_LOCK_FILE="$tmp/hardware.lock"
export XDG_STATE_HOME="$tmp/state"
printf '%s\n' '#!/usr/bin/env bash' \
'if [[ "$1" == "link" && "$2" == "show" ]]; then exit 0; fi' \
'if [[ "$1" == "route" && "$2" == "show" ]]; then echo "default via 192.0.2.1 dev wlan-main"; exit 0; fi' \
'echo "192.168.4.1 dev wlan-client src 192.168.4.2"' >"$stub_bin/ip"
printf '%s\n' '#!/usr/bin/env bash' \
'printf "%s\\n" "$*" >>"$CALL_LOG"' \
'if [[ "${NMCLI_FAIL_UP:-}" == "yes" && "$1" == "connection" && "$2" == "up" ]]; then exit 7; fi' \
'if [[ "$1" == "-t" && "$2" == "-f" && "$3" == "SSID" ]]; then echo "WM Contract ESP8266"; exit 0; fi' \
'if [[ "$1" == "-g" && "$2" == "connection.uuid" ]]; then echo "stub-uuid"; exit 0; fi' \
'if [[ "$1" == "-g" ]]; then echo "--"; fi' >"$stub_bin/nmcli"
printf '%s\n' '#!/usr/bin/env bash' 'exit 0' >"$stub_bin/pio"
printf '%s\n' '#!/usr/bin/env bash' \
'printf "docker %s\n" "$*" >>"$CALL_LOG"' \
'if [[ "$1" == "compose" && "$2" == "version" ]]; then echo "Docker Compose"; exit 0; fi' \
'exit 0' >"$stub_bin/docker"
chmod 755 "$stub_bin"/*
export PATH="$stub_bin:$PATH"
"$root/tools/portal-hardware" doctor --client-interface wlan-client >/dev/null
if grep -Eq 'connection (add|modify|delete)|device disconnect' "$CALL_LOG"; then
echo 'doctor unexpectedly changed a NetworkManager connection' >&2
exit 1
fi
if "$root/tools/portal-hardware" doctor --client-interface wlan-main >/dev/null 2>&1; then
echo 'default-route adapter guard did not reject the request' >&2
exit 1
fi
if "$root/tools/portal-hardware" up --platform >/dev/null 2>&1; then
echo 'missing option value did not reject the request' >&2
exit 1
fi
# A failed association must delete the only connection it just created.
source "$root/tools/lib/portal-hardware-session.sh"
wm_wait_for_portal_ssid() { return 0; }
export NMCLI_FAIL_UP=yes
if wm_create_portal_connection wlan-client 'fixture portal' placeholder; then
echo 'failed association was reported as success' >&2
exit 1
fi
unset NMCLI_FAIL_UP
grep -Eq 'connection delete wifimanager-portal-' "$CALL_LOG"
# A retained session must be removed explicitly, never silently overwritten.
wm_write_state wlan-client esp8266 stale-uuid stale-name
wm_load_state
[[ "$WM_PORTAL_INTERFACE" == "wlan-client" && "$WM_PORTAL_PLATFORM" == "esp8266" ]]
[[ "$WM_PORTAL_CONNECTION_UUID" == "stale-uuid" && "$WM_PORTAL_CONNECTION_NAME" == "stale-name" ]]
mutations_before="$(grep -Ec '^(device disconnect|connection (add|modify|delete|down))' "$CALL_LOG" || true)"
if "$root/tools/portal-hardware" up --platform esp8266 --port /dev/null \
--client-interface wlan-client --take-over-client-adapter >/dev/null 2>&1; then
echo 'stale portal session was silently overwritten' >&2
exit 1
fi
mutations_after="$(grep -Ec '^(device disconnect|connection (add|modify|delete|down))' "$CALL_LOG" || true)"
[[ "$mutations_before" == "$mutations_after" ]] || {
echo 'stale portal session mutated the selected adapter' >&2
exit 1
}
wm_clear_state
# The runner must build the copied contract source before it starts the container.
"$root/tools/portal-hardware" run --platform esp8266 --port /dev/null --client-interface wlan-client --browser skip >/dev/null
build_line="$(grep -n " build portal-contract$" "$CALL_LOG" | tail -1 | cut -d: -f1)"
run_line="$(grep -n " run --rm portal-contract$" "$CALL_LOG" | tail -1 | cut -d: -f1)"
[[ -n "$build_line" && -n "$run_line" && "$build_line" -lt "$run_line" ]] || {
echo "portal contract was not rebuilt before execution" >&2
exit 1
}
echo 'portal-hardware CLI safety checks passed'