mirror of
https://github.com/alexhopeoconnor/WiFiManager.git
synced 2026-10-04 02:48:13 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
360b7cff86 | ||
|
|
7c5b50499e | ||
|
|
3e09958d11 | ||
|
|
f1fdeec8ee | ||
|
|
4aab676574 | ||
|
|
d1ad84db5b | ||
|
|
babc58637f | ||
|
|
228e1252f4 | ||
|
|
7305ea8827 | ||
|
|
9fec8799ac | ||
|
|
6500eee528 | ||
|
|
50972914e3 | ||
|
|
4fede428c4 | ||
|
|
20e75622ac | ||
|
|
5304aad4c8 | ||
|
|
21607d41bd | ||
|
|
5592fd12e1 | ||
|
|
9b070f45f4 | ||
|
|
daf9f207b2 | ||
|
|
d57f6c91c5 | ||
|
|
fcd991c5a2 | ||
|
|
9707d18b2b | ||
|
|
242f60fa99 | ||
|
|
28c095c2d1 | ||
|
|
6920163026 | ||
|
|
e2dcf9a1f3 | ||
|
|
d2265e4a0d | ||
|
|
3e9f12e3ac | ||
|
|
2fec33b952 | ||
|
|
f8b87f7865 | ||
|
|
e192c5a94c | ||
|
|
c529fc15a8 | ||
|
|
25977a7320 |
@@ -0,0 +1,14 @@
|
||||
# Keep local credentials, build output, and test artifacts out of the Docker
|
||||
# build context. OTA firmware is mounted read-only by compose.ota.yaml instead
|
||||
# of copied into an image.
|
||||
.git
|
||||
.github
|
||||
.pio
|
||||
**/.pio
|
||||
node_modules
|
||||
**/node_modules
|
||||
artifacts
|
||||
test/portal-station.env
|
||||
test/.env
|
||||
platformio.local.ini*
|
||||
**/platformio.local.ini*
|
||||
@@ -2,22 +2,59 @@ 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/check-ota-partitions.sh tools/portal-hardware tools/lib/*.sh tools/tests/*.sh
|
||||
- run: python3 -m py_compile test/portal-harness/tools/ota_fixture_input.py
|
||||
- run: |
|
||||
for spec in tests/portal-harness/tests/*.js; do
|
||||
node --check "$spec"
|
||||
done
|
||||
- run: timeout 15s python3 tools/tests/test-capture-serial.py
|
||||
- run: timeout 15s bash tools/tests/test-ota-fixture-identity.sh
|
||||
- run: ./tools/check-ota-partitions.sh
|
||||
- run: ./scripts/check-docs.sh
|
||||
|
||||
compile-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
environment: ["esp8266", "esp32"]
|
||||
include:
|
||||
- platform: esp8266
|
||||
examples: true
|
||||
ota_fixtures: true
|
||||
unity: true
|
||||
- platform: esp32
|
||||
examples: true
|
||||
ota_fixtures: true
|
||||
unity: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
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.platform }}
|
||||
- if: matrix.unity
|
||||
run: ./scripts/test.sh unity --platform ${{ matrix.platform }}
|
||||
- if: matrix.examples
|
||||
run: ./scripts/test.sh examples --platform ${{ matrix.platform }}
|
||||
- if: matrix.ota_fixtures
|
||||
run: ./scripts/test.sh ota-fixtures --platform ${{ matrix.platform }}
|
||||
|
||||
@@ -6,18 +6,64 @@ on:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
documentation:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: bash -n scripts/*.sh tools/check-ota-partitions.sh tools/portal-hardware tools/lib/*.sh tools/tests/*.sh
|
||||
- run: |
|
||||
for spec in tests/portal-harness/tests/*.js; do
|
||||
node --check "$spec"
|
||||
done
|
||||
- run: timeout 15s python3 tools/tests/test-capture-serial.py
|
||||
- run: ./tools/check-ota-partitions.sh
|
||||
- run: ./scripts/check-docs.sh
|
||||
|
||||
compile-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: esp8266
|
||||
examples: true
|
||||
ota_fixtures: true
|
||||
unity: true
|
||||
- platform: esp32
|
||||
examples: true
|
||||
ota_fixtures: true
|
||||
unity: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: python -m pip install --upgrade platformio==6.1.19
|
||||
- run: ./scripts/test.sh compile --platform ${{ matrix.platform }}
|
||||
- if: matrix.unity
|
||||
run: ./scripts/test.sh unity --platform ${{ matrix.platform }}
|
||||
- if: matrix.examples
|
||||
run: ./scripts/test.sh examples --platform ${{ matrix.platform }}
|
||||
- if: matrix.ota_fixtures
|
||||
run: ./scripts/test.sh ota-fixtures --platform ${{ matrix.platform }}
|
||||
|
||||
publish:
|
||||
needs:
|
||||
- documentation
|
||||
- compile-tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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 }}
|
||||
|
||||
@@ -33,3 +33,8 @@ Thumbs.db
|
||||
|
||||
# Misc
|
||||
node_modules/
|
||||
|
||||
# Local portal station handoff credentials and optional direct test artifacts
|
||||
/test/portal-station.env
|
||||
/tests/portal-harness/artifacts/
|
||||
/artifacts/
|
||||
|
||||
+49
-1
@@ -1,5 +1,54 @@
|
||||
# Changelog
|
||||
|
||||
## 3.2.5
|
||||
|
||||
- Prevent ESP8266 portal firmware uploads from yielding in ESPAsyncWebServer's
|
||||
SYS callback. The updater now enters asynchronous mode before its first
|
||||
erase or write, so the real browser upload can complete and restart into
|
||||
the new firmware image.
|
||||
- Send the successful portal OTA response before scheduling the restart, so
|
||||
browsers can observe a completed HTTP exchange on both ESP8266 and ESP32.
|
||||
- Give ESP32's Wi-Fi radio a five-second hand-off interval before a user scan
|
||||
retry, avoiding transient scan failures immediately after completion.
|
||||
|
||||
## 3.2.4
|
||||
|
||||
- Correct profile-backed Wi-Fi hand-off from the embedded web portal: the
|
||||
station controller now owns attempts started through either portal surface,
|
||||
preventing the legacy empty-SSID path from reporting success before a
|
||||
station address is available.
|
||||
|
||||
## 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 +78,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.
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
# 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)
|
||||

|
||||
|
||||
## Basic provisioning
|
||||
WiFiManager provides a self-hosted setup portal for Wi-Fi and application
|
||||
settings without copying portal HTML into each firmware. See the detailed
|
||||
[portal UI guide](docs/PORTAL_UI.md) for supported branding and content APIs.
|
||||
|
||||
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 +21,94 @@ WiFiManager wifi;
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
wifi.setConfigPortalTimeout(180); // seconds; 0 leaves it open
|
||||
|
||||
if (!wifi.autoConnect("Device Setup", "change-me")) {
|
||||
ESP.restart();
|
||||
}
|
||||
// Leave the temporary setup portal available for three minutes.
|
||||
wifi.setConfigPortalTimeout(180);
|
||||
|
||||
// Reconnect to saved Wi-Fi, or open the setup portal when none works.
|
||||
wifi.autoConnect("Device Setup", "change-me");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// Normal application work after WiFi is connected.
|
||||
// Service portal requests and connection state without blocking firmware work.
|
||||
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.
|
||||
|
||||
## Building a Home Assistant device?
|
||||
|
||||
## Breaking Changes
|
||||
WiFiManager remains a standalone provisioning library. If a device also needs
|
||||
persistent configuration, MQTT, Home Assistant discovery, OTA, mDNS, and an
|
||||
optional local web UI, see
|
||||
[DeviceFramework](https://github.com/alexhopeoconnor/DeviceFramework), which
|
||||
integrates this portal as part of that larger device lifecycle.
|
||||
|
||||
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.5
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,120 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
## Target pins
|
||||
|
||||
WiFiManager uses one maintained ESP32 test lane:
|
||||
|
||||
| Lane | pioarduino platform | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `esp32` | `55.03.311` / Arduino-ESP32 3.3.11 | maintained baseline |
|
||||
|
||||
This is a test-target policy, not a library-manifest dependency: a consuming
|
||||
application chooses its own `platform` and must validate the complete
|
||||
framework/toolchain stack. Do not let a shared global PlatformIO cache choose
|
||||
framework metadata or a compiler implicitly, and do not override just the
|
||||
toolchain to repair a cache mismatch. Each pioarduino platform owns its
|
||||
matching framework, uploader, and compiler package set.
|
||||
|
||||
Core 3 Wi-Fi builds need the C++14, `SOC_WIFI_SUPPORTED`, and `Network/src`
|
||||
settings in this repository's `platformio.ini`; keep those settings together
|
||||
when adding an ESP32 environment. The portal OTA fixture and every guided
|
||||
example use this 3.3.11 ESP32 baseline.
|
||||
|
||||
ESP8266 test environments pin framework commit `521ae60` for the upstream
|
||||
Postmortem large-jump linker fix. The exact rationale and update rule are in
|
||||
the shared [ESP8266 linker-workaround note](https://github.com/alexhopeoconnor/arduino-home-assistant/blob/main/docs/ESP8266-LINKER-WORKAROUND.md).
|
||||
For the pioarduino release-to-Core mapping and cache-collision diagnosis, see
|
||||
[DeviceFramework's toolchain guide](https://github.com/alexhopeoconnor/DeviceFramework/blob/main/docs/TOOLCHAINS.md).
|
||||
|
||||
`./scripts/test.sh` and `./tools/portal-hardware ota --platform esp32` use the
|
||||
PlatformIO Core/cache shared by the maintained framework repositories,
|
||||
defaulting to `${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11`.
|
||||
WiFiManager, DeviceFramework, DFTE, and ArduinoHA pin this same graph, so this
|
||||
avoids downloading the same Core 3.3.11 inputs for each repository while
|
||||
keeping pioarduino's package-form `esptool` and generated environment separate
|
||||
from stale global `tool-esptoolpy` metadata. Override the location with
|
||||
`WIFIMANAGER_PLATFORMIO_CORE_DIR`,
|
||||
`WIFIMANAGER_PLATFORMIO_PACKAGES_DIR`, and
|
||||
`WIFIMANAGER_PLATFORMIO_CACHE_DIR` when space belongs elsewhere or an isolated
|
||||
diagnosis is needed. The first shared install is several GiB; reserve at least
|
||||
4 GiB plus cache headroom. It is persistent and is never cleared by normal test
|
||||
commands.
|
||||
|
||||
For a disposable cache investigation, point that variable at an exact temporary
|
||||
directory, run the affected command, inspect the resolved graph, then remove
|
||||
only that directory:
|
||||
|
||||
```bash
|
||||
wm_pio_core="$(mktemp -d /tmp/wifimanager-pio-XXXXXX)"
|
||||
WIFIMANAGER_PLATFORMIO_CORE_DIR="$wm_pio_core" \
|
||||
./scripts/test.sh compile --platform esp32
|
||||
rm -rf -- "$wm_pio_core"
|
||||
```
|
||||
|
||||
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/test.sh unity --platform esp8266
|
||||
./scripts/test.sh unity --platform esp32
|
||||
./scripts/test.sh examples --platform esp8266
|
||||
./scripts/test.sh examples --platform esp32
|
||||
./scripts/test.sh ota-fixtures --platform esp8266
|
||||
./scripts/test.sh ota-fixtures --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 test harness 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
|
||||
```
|
||||
|
||||
From an SSH/headless shell, the portal command may ask once for scoped
|
||||
NetworkManager sudo authorization before the selected board is erased. This is
|
||||
host setup, not a test secret; never add a sudo value to an env file or run the
|
||||
whole runner as root. See [Testing](TESTING.md#networkmanager-authorization)
|
||||
for the direct/Polkit and scoped-sudo behavior.
|
||||
|
||||
See [Testing](TESTING.md#docker-portal-test-harness) for cleanup, artifacts, and
|
||||
optional station handoff credentials.
|
||||
|
||||
Run the portal HTTP OTA A/B test harness separately when a spare adapter and 4 MB
|
||||
test board are available. It erases the selected board's flash, serial-flashes
|
||||
A, and uses the real browser update form to upload B; do not replace its
|
||||
automatic-reboot assertion with a manual reset:
|
||||
|
||||
```bash
|
||||
./tools/portal-hardware ota --platform esp8266 --port /dev/serial/by-id/usb-... \
|
||||
--client-interface wlx74da385d4165
|
||||
```
|
||||
|
||||
See [Portal HTTP OTA A/B test harness](TESTING.md#portal-http-ota-ab-test-harness) for
|
||||
the partition, artifact, final-board-state, and adapter rules.
|
||||
|
||||
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).
|
||||
@@ -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.5
|
||||
```
|
||||
|
||||
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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,118 @@
|
||||
# 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": "success",
|
||||
"message": "human readable status",
|
||||
"wifiStatus": "WL_CONNECTED",
|
||||
"stationIp": "192.168.1.42",
|
||||
"redirectUrl": "http://192.168.1.42/"
|
||||
}
|
||||
~~~
|
||||
|
||||
`state` is one of `idle`, `waiting`, `success`, or `failed`. The example shows
|
||||
a successful join; `stationIp` and `redirectUrl` are present only in that state.
|
||||
If the portal server is not on port 80, `redirectUrl` includes that port. When
|
||||
connect-on-save is disabled, a saved configuration has no station address and
|
||||
the built-in portal remains open.
|
||||
|
||||
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).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,134 @@
|
||||
# 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.
|
||||
|
||||
## Portal views
|
||||
|
||||
These ESP32 captures use the same real-board portal test harness described in
|
||||
[Testing](TESTING.md). The nearby networks shown are the networks visible to
|
||||
the capture device when the portal scans.
|
||||
|
||||
| Overview | Wi-Fi and application settings |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
## 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 the maintained ESP32 3.3.11 baseline.
|
||||
|
||||
## 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).
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,108 @@
|
||||
# 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. The manager neither allocates nor owns it. This complete in-memory version makes the ownership and return contract visible; use the buildable EEPROM example when the profiles must survive a restart:
|
||||
|
||||
```cpp
|
||||
class MemoryProfileStore final : public WiFiManagerStationProfileStore {
|
||||
public:
|
||||
bool load(WiFiManagerStationProfiles& profiles) override {
|
||||
if (!hasProfiles_) return false; // No saved primary profile: open the portal.
|
||||
profiles = profiles_;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool save(const WiFiManagerStationProfiles& profiles) override {
|
||||
profiles_ = profiles;
|
||||
hasProfiles_ = true;
|
||||
return true; // A durable store must return false when its write fails.
|
||||
}
|
||||
|
||||
bool clear() override {
|
||||
profiles_ = {};
|
||||
hasProfiles_ = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
WiFiManagerStationProfiles profiles_{};
|
||||
bool hasProfiles_ = false;
|
||||
};
|
||||
|
||||
WiFiManager wifi;
|
||||
MemoryProfileStore profiles; // Must outlive WiFiManager's asynchronous connection work.
|
||||
|
||||
void setup() {
|
||||
wifi.setStationProfileStore(&profiles);
|
||||
wifi.setStationRecoveryInterval(30000);
|
||||
wifi.startStationConnection("Example Setup", "setup-password");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
wifi.process(); // Advances profile retries and serves the fallback portal.
|
||||
}
|
||||
```
|
||||
|
||||
This memory-only store intentionally loses profiles on restart. The store must return a complete `WiFiManagerStationProfiles` value. Each enabled profile has a NUL-terminated SSID of at most 32 characters and an optional NUL-terminated password of at most 64 characters. Keep slot 0 enabled; set `hasPassword = false` for an open network.
|
||||
|
||||
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).
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
# Testing
|
||||
|
||||
WiFiManager separates repeatable board-free builds 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.
|
||||
|
||||
| Physical test harness | Transport | Host adapter | Secret source | Required proof |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Portal lifecycle suite | Serial flash + captive-portal HTTP/browser | Named secondary adapter | safe fixture AP password | Unity/lifecycle checks and portal UI/API coverage |
|
||||
| Portal HTTP OTA | WiFiManager multipart `POST /u` | Named secondary adapter | safe fixture AP password | rendered upload succeeds, portal restarts automatically, and fixture marker changes A → B twice |
|
||||
|
||||
The selected secondary adapter is intentionally never used for normal LAN
|
||||
testing. It is `never-default`, so the host's ordinary route remains intact.
|
||||
|
||||
## Board-free fixture, consumer, and example builds
|
||||
|
||||
The Unity compile check builds WiFiManager's own fixture without a board. The
|
||||
consumer check builds a project that declares only WiFiManager, proving that a
|
||||
normal PlatformIO dependency resolution can compile DFTE, ESPAsyncWebServer,
|
||||
and the correct ESP8266 or ESP32 TCP dependency. Normal commands reuse the
|
||||
persistent PlatformIO cache; they do not delete, reinstall, or separately
|
||||
assert the package graph.
|
||||
|
||||
```bash
|
||||
./scripts/test.sh compile --platform esp8266
|
||||
./scripts/test.sh compile --platform esp32
|
||||
./scripts/test.sh unity --platform esp8266
|
||||
./scripts/test.sh unity --platform esp32
|
||||
./scripts/test.sh examples --platform esp8266
|
||||
./scripts/test.sh examples --platform esp32
|
||||
./scripts/test.sh ota-fixtures --platform esp8266
|
||||
./scripts/test.sh ota-fixtures --platform esp32
|
||||
```
|
||||
|
||||
CI runs these board-free checks for pull requests and pushes to the maintained
|
||||
branch. `esp32` uses Arduino-ESP32 3.3.11 and compiles the guided examples.
|
||||
The OTA fixture builds compile both immutable A
|
||||
and B images against their tracked OTA partition layout. CI rejects equal A/B
|
||||
artifacts, an ESP32 image larger than either 0x1F0000-byte app slot, or a
|
||||
partition-table edit that breaks the required two-slot/no-filesystem layout. These checks
|
||||
intentionally do not require attached hardware, a local network, or Docker.
|
||||
|
||||
Direct PlatformIO test-harness commands default to two compiler jobs. Set
|
||||
`PLATFORMIO_RUN_JOBS=3` only for an explicit local run on an otherwise idle
|
||||
host.
|
||||
|
||||
Physical portal commands lock the shared `192.168.4.0/24` portal network, the
|
||||
selected secondary adapter, and the named serial device. These non-secret
|
||||
resource locks are shared with DeviceFramework's portal harness, so a collision
|
||||
fails before either runner changes a board or adapter while unrelated station
|
||||
tests can use their own resources.
|
||||
|
||||
## 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 test harness and DeviceFramework's hardware runners on the same host, so
|
||||
two first-party invocations cannot flash or use the same board at once.
|
||||
|
||||
## Docker portal test harness
|
||||
|
||||
`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 test harness 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.
|
||||
|
||||
The runner resolves PlatformIO from `WIFIMANAGER_PIO_EXECUTABLE`, then `PATH`,
|
||||
then PlatformIO's standard `~/.platformio/penv/bin/pio` installation. That
|
||||
makes the same command work from a non-interactive SSH shell without modifying
|
||||
the user's `PATH`.
|
||||
|
||||
### NetworkManager authorization
|
||||
|
||||
The portal adapter is a host-side resource, separate from fixture credentials.
|
||||
The runner never reads a sudo password from `test/.env`, an environment file,
|
||||
or source control. `doctor` reports whether the current session can use
|
||||
NetworkManager directly or will need scoped sudo. In a graphical desktop,
|
||||
Polkit normally authorizes the selected adapter directly. In an SSH or other
|
||||
headless session with no Polkit agent, the runner visibly validates `sudo -v`
|
||||
before it erases or flashes the board, then uses `sudo -n nmcli` only to scan,
|
||||
disconnect, join, and remove its generated connection on the named secondary
|
||||
adapter.
|
||||
|
||||
The normal setting is `WM_NMCLI_AUTH=auto`. Use `WM_NMCLI_AUTH=sudo` to choose
|
||||
the same scoped path deliberately, or `WM_NMCLI_AUTH=direct` only when a
|
||||
working Polkit policy already grants the required actions. Do not run the whole
|
||||
runner under `sudo`: its state files and browser artifacts intentionally remain
|
||||
owned by the invoking developer. If the sudo ticket expires during a long run,
|
||||
the runner stops with an actionable message rather than silently treating an
|
||||
unauthorized rescan as a missing portal SSID. `down` uses the same scoped path
|
||||
to remove a retained connection.
|
||||
|
||||
```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 and includes thirteen harmless
|
||||
custom parameters. It verifies root/bootstrap/info/status API responses,
|
||||
concurrent low-priority requests, every custom field and value across repeated
|
||||
API fetches, timeout reset, an actual async scan, a missing-route response, and
|
||||
desktop/mobile portal rendering with no browser page errors. It also round-trips
|
||||
a value containing apostrophes, quotes, backslashes, angle brackets, and an
|
||||
ampersand through the rendered form and parameter-save API. 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 test harness deliberately retries
|
||||
that transport interruption and still requires a reachable portal with a
|
||||
complete, valid scan result.
|
||||
|
||||
The normal browser test harness catches the common regression case. When changing
|
||||
parameter rendering, run the opt-in ESP8266 soak as well. It performs twelve
|
||||
full browser renders and API fetches while the AP is active, asserting all
|
||||
thirteen fields and their exact values on every pass. This targets the
|
||||
memory-sensitive rendering failure reported upstream in issue #1787 without
|
||||
making every ordinary hardware run unnecessarily long:
|
||||
|
||||
```bash
|
||||
./tools/portal-hardware run \
|
||||
--platform esp8266 \
|
||||
--port /dev/serial/by-id/usb-... \
|
||||
--client-interface wlx74da385d4165 \
|
||||
--custom-parameter-stress
|
||||
```
|
||||
|
||||
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. The runner parses only `WIFI_SSID` and
|
||||
`WIFI_PASSWORD` into a generated mode-600 two-key file, mounts that file
|
||||
read-only into the test container, and removes it after the browser run; it
|
||||
never mounts the complete local environment file or logs either value. Because
|
||||
browser traces can retain request bodies, this opt-in mode disables Playwright
|
||||
screenshots, video, and tracing, including explicit diagnostic screenshots. It
|
||||
cannot be combined with README-media capture. Docker builds from the tracked
|
||||
`tests/portal-harness` directory only, so neither the source environment file
|
||||
nor the generated two-key file enters its build context. Keep its private output
|
||||
directory private and review any remaining report before sharing it.
|
||||
|
||||
After either a passing or failing station-handoff attempt, the runner
|
||||
serial-flashes the portal-only fixture once more. Its `setup()` clears saved
|
||||
station settings, so the selected test board returns to the clean no-station
|
||||
portal state and does not retain the developer's Wi-Fi credentials. A failed
|
||||
restore or a failure to see the cleaned fixture AP return makes the command
|
||||
fail. `--keep` affects only the runner's temporary
|
||||
secondary-adapter connection; it does not retain station credentials on the
|
||||
board.
|
||||
|
||||
A retained session is deliberately never overwritten. Before touching
|
||||
NetworkManager, the runner atomically records its uniquely generated connection
|
||||
name; after creation it atomically replaces that pending record with the exact
|
||||
UUID. Ordinary failures and interrupts remove that connection immediately, and
|
||||
`down` accepts either record after an uncatchable host termination or an
|
||||
intentional `up`/`--keep` session. It never removes another NetworkManager
|
||||
connection.
|
||||
|
||||
```bash
|
||||
cp test/portal-station.env.example test/portal-station.env
|
||||
./tools/portal-hardware run ... --station-env test/portal-station.env
|
||||
```
|
||||
|
||||
## Refresh README media
|
||||
|
||||
README media is an explicit ESP32-only capture, not part of normal testing or
|
||||
CI. It uses the same real-board portal test harness above, but records a short
|
||||
browser tour and stores all candidate files under the ignored
|
||||
`artifacts/readme-media/` directory by default:
|
||||
|
||||
```bash
|
||||
./tools/portal-hardware run \
|
||||
--platform esp32 \
|
||||
--port /dev/serial/by-id/usb-... \
|
||||
--client-interface USB_WIFI_ADAPTER \
|
||||
--capture-readme-media
|
||||
```
|
||||
|
||||
Review the printed artifact directory. To keep a run somewhere more convenient,
|
||||
pass `--output DIRECTORY`. After review, promote only the approved PNG/GIF
|
||||
files into tracked documentation assets:
|
||||
|
||||
```bash
|
||||
./tools/promote-readme-media \
|
||||
--from artifacts/readme-media/TIMESTAMP-esp32 \
|
||||
--replace
|
||||
./scripts/check-docs.sh
|
||||
```
|
||||
|
||||
The Docker renderer validates the GIF duration. The promotion tool requires the
|
||||
successful ESP32 media manifest, checks file types and size limits, and never
|
||||
copies raw video, browser reports, traces, or arbitrary artifact files. The
|
||||
renderer preserves the real recording but deliberately presents it at 1.25×
|
||||
duration and 6 fps so the
|
||||
README tour is readable; it does not change normal browser-test-harness timing.
|
||||
ESP8266 remains covered by the normal hardware and browser test harness but does
|
||||
not produce duplicate README media.
|
||||
|
||||
## Portal HTTP OTA A/B test harness
|
||||
|
||||
`portal-hardware ota` is a separate opt-in physical test for WiFiManager's
|
||||
built-in HTTP update path. It exercises the rendered firmware-update page and
|
||||
its real multipart `POST /u` request; it is not an ArduinoOTA/UDP test.
|
||||
|
||||
```bash
|
||||
./tools/portal-hardware ota \
|
||||
--platform esp8266 \
|
||||
--port /dev/serial/by-id/usb-... \
|
||||
--client-interface wlx74da385d4165
|
||||
```
|
||||
|
||||
The selected `--client-interface` has exactly the same safety rules as the
|
||||
normal portal test harness: it must be the explicitly named secondary adapter and
|
||||
cannot be the host default-route interface. The test never attaches that
|
||||
adapter to a normal station network. The fixture AP uses the safe local
|
||||
`default1` WPA password; this is an AP-access test, not a claim that `/u` has
|
||||
HTTP route authentication.
|
||||
|
||||
The test harness performs the following complete run:
|
||||
|
||||
1. Builds immutable A and B fixture images from one platform environment. A
|
||||
generated harness-only header in an ignored, per-run private directory is
|
||||
the only changed input, so their marker is compiled into the binary rather
|
||||
than saved in WiFiManager settings or EEPROM.
|
||||
2. Checks both ESP32 images against the explicit matching `app0`/`app1` slots;
|
||||
ESP8266 validates B after A has booted against the exact aligned capacity
|
||||
passed to `Update.begin()`.
|
||||
3. Erases the explicitly selected test board's flash, then flashes A over
|
||||
serial and starts its captive portal.
|
||||
4. Joins that portal only through the named secondary adapter and requires the
|
||||
A marker at `/api/test/firmware-marker`.
|
||||
5. Mounts B read-only into the Playwright container, chooses it in the real
|
||||
`#wm-ota-file` browser input, and submits the rendered form.
|
||||
6. Requires the real `POST /u` success response, an automatic portal outage,
|
||||
automatic restart, and two independent B-marker responses.
|
||||
|
||||
The OTA command additionally requires Python with PySerial (the
|
||||
`python3-serial` package on Debian/Ubuntu) and retains a passive,
|
||||
no-reset `serial-ota.log` beside the browser artifacts. It attaches immediately
|
||||
after serial-flashing A releases the port—before portal association and the A
|
||||
marker check—and remains attached through the two B checks. A passing run
|
||||
requires a healthy recorder, but its contents are diagnostic evidence rather
|
||||
than a pass/fail comparison against product log strings. This preserves
|
||||
firmware-side portal-start and DHCP evidence without manufacturing a reset.
|
||||
OTA-only fixture images wait five seconds after their upload reset so
|
||||
the passive recorder can attach before A/B boot evidence is emitted; ordinary
|
||||
portal test-harness startup remains fast.
|
||||
|
||||
The fixture marker endpoint exists only in `test/portal-harness`; it is not a
|
||||
WiFiManager library route or a product-firmware pattern. The test does not
|
||||
issue a manual reset. A board which boots B only after intervention is a
|
||||
failure, even if B later appears.
|
||||
|
||||
Both fixture images are built with explicit OTA-capable layouts:
|
||||
|
||||
| Platform | Fixture layout | Capacity check |
|
||||
| --- | --- | --- |
|
||||
| ESP8266 | `eagle.flash.4m1m.ld` | A's live `ESP.getFreeSketchSpace()` response |
|
||||
| ESP32 | two `0x1F0000` A/B app slots, no filesystem | tracked CSV `app1` size |
|
||||
|
||||
These are 4 MB fixture layouts (`d1_mini` for ESP8266 and `esp32dev` for
|
||||
ESP32). Do not run this command against a board with another flash size unless
|
||||
its matching explicit A/B layout and capacity checks have been added first.
|
||||
|
||||
The final board state is firmware B in the portal-only fixture: it clears
|
||||
saved station settings on every boot and leaves no developer Wi-Fi credential
|
||||
on the device. By default the temporary NetworkManager connection is removed
|
||||
when the test exits. Pass `--keep` only for interactive diagnosis, then run
|
||||
`./tools/portal-hardware down` to remove that named temporary connection.
|
||||
|
||||
Back to [documentation](README.md) · [project overview](../README.md).
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 62 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).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,20 @@
|
||||
#include <Arduino.h>
|
||||
#include <WiFiManager.h>
|
||||
|
||||
WiFiManager portal;
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
portal.setConfigPortalTimeout(180); // Do not leave a first-boot setup AP open forever.
|
||||
|
||||
// Returns true when saved station credentials connect; otherwise opens the portal.
|
||||
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(); // Keeps DNS, HTTP, and station-recovery work responsive.
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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/55.03.311/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,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(); }
|
||||
@@ -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).
|
||||
@@ -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/55.03.311/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,43 @@
|
||||
#include <Arduino.h>
|
||||
#include <WiFiManager.h>
|
||||
|
||||
WiFiManager portal;
|
||||
// WiFiManager reads this object while the portal is open, so it must outlive setup().
|
||||
WiFiManagerParameter brokerHost("broker_host", "MQTT broker", "mqtt.local", 40);
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
portal.portalAddParameter(&brokerHost); // Adds an application-owned setting to the built-in form.
|
||||
|
||||
// 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) {
|
||||
// Validate and persist a copy in the application; this example only reports it.
|
||||
Serial.print("MQTT broker selected: ");
|
||||
Serial.println(brokerHost.getValue());
|
||||
});
|
||||
portal.setConfigPortalTimeout(180);
|
||||
|
||||
portal.autoConnect("WiFiManager Content", "example-pass");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
portal.process(); // Serves portal requests until provisioning completes or times out.
|
||||
}
|
||||
@@ -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/55.03.311/platform-espressif32.zip
|
||||
board = esp32dev
|
||||
build_unflags = -std=gnu++11
|
||||
build_flags =
|
||||
-std=gnu++14
|
||||
-DSOC_WIFI_SUPPORTED=1
|
||||
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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).
|
||||
@@ -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,68 @@
|
||||
#include <Arduino.h>
|
||||
#include <EEPROM.h>
|
||||
#include <WiFiManager.h>
|
||||
|
||||
namespace {
|
||||
constexpr uint32_t kStoreMagic = 0x574D5031; // "WMP1"
|
||||
|
||||
struct StoredProfiles {
|
||||
uint32_t magic;
|
||||
WiFiManagerStationProfiles profiles;
|
||||
};
|
||||
|
||||
// The application owns persistence; WiFiManager only chooses and verifies profiles.
|
||||
class EepromProfileStore final : public WiFiManagerStationProfileStore {
|
||||
public:
|
||||
bool begin() {
|
||||
#if defined(ESP32)
|
||||
// ESP32 EEPROM emulation can fail to reserve its backing region.
|
||||
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) {
|
||||
// Treat erased or unrelated EEPROM as having no profiles.
|
||||
return false;
|
||||
}
|
||||
profiles = stored.profiles;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool save(const WiFiManagerStationProfiles& profiles) override {
|
||||
// WiFiManager calls this only after it has verified the submitted candidate.
|
||||
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;
|
||||
}
|
||||
|
||||
// Both objects are global because station retries continue after setup() returns.
|
||||
portal.setStationProfileStore(&profileStore);
|
||||
portal.setStationRecoveryInterval(30000);
|
||||
portal.startStationConnection("WiFiManager Profiles", "example-pass");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
portal.process(); // Advances connection attempts and serves provisioning when needed.
|
||||
}
|
||||
@@ -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/55.03.311/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
|
||||
@@ -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;
|
||||
@@ -308,7 +337,16 @@ class WiFiManager
|
||||
unsigned long startedAt = 0;
|
||||
unsigned long finishedAt = 0;
|
||||
unsigned long timeoutMs = 15000;
|
||||
// ESP32's high-level scan-complete notification can arrive before the
|
||||
// radio has fully released its previous scan. A second scan started in
|
||||
// that short window is reported only as WIFI_SCAN_FAILED by Arduino.
|
||||
// Keep the established short ESP8266 cadence, but give ESP32 its
|
||||
// documented radio hand-off time before retrying a user refresh.
|
||||
#ifdef ESP32
|
||||
unsigned long minRestartIntervalMs = 5000;
|
||||
#else
|
||||
unsigned long minRestartIntervalMs = 2000;
|
||||
#endif
|
||||
uint32_t generation = 0;
|
||||
uint32_t runningGeneration = 0;
|
||||
uint32_t completionGeneration = 0;
|
||||
@@ -327,6 +365,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 +393,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 +412,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 +517,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 +542,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 +592,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 +635,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 +648,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 +662,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 +689,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 +698,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 +710,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 +721,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 +742,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 +797,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 +816,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 +890,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 +915,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 +944,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 +965,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 +1021,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 +1049,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
|
||||
|
||||
|
||||
@@ -952,6 +1070,7 @@ protected:
|
||||
void wmTestForceScanState(wm_scan_state_t state) { _scan.state = state; }
|
||||
void wmTestSetScanStartedAt(unsigned long startedAt) { _scan.startedAt = startedAt; }
|
||||
void wmTestSetScanTimeoutMs(unsigned long timeoutMs) { _scan.timeoutMs = timeoutMs; }
|
||||
unsigned long wmTestGetScanRestartIntervalMs() const { return _scan.minRestartIntervalMs; }
|
||||
void wmTestInjectScanResults(const std::vector<WiFiScanNetwork>& results) {
|
||||
_scanResultsCache = results;
|
||||
_numNetworks = static_cast<int>(results.size());
|
||||
@@ -973,12 +1092,19 @@ protected:
|
||||
_configPortalStart = millis();
|
||||
}
|
||||
}
|
||||
void wmTestSetWebPortalActive(bool active) {
|
||||
webPortalActive = active;
|
||||
if (active) {
|
||||
_configPortalStart = millis();
|
||||
}
|
||||
}
|
||||
void wmTestSetPortalConnectSuccess(const String& message, const String& stationIp, uint8_t status = WL_CONNECTED) {
|
||||
_cpConnectState = wm_cp_connect_state_t::success;
|
||||
_cpConnectMessage = message;
|
||||
_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 +1168,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";
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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>";
|
||||
|
||||
|
||||
@@ -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,499 @@ 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;
|
||||
// A profile submitted from either portal surface is owned by the station
|
||||
// controller. Do not let the legacy empty-SSID portal state machine mark
|
||||
// it successful before the controller has a usable station address.
|
||||
_stationCandidateFromPortal = configPortalActive || webPortalActive;
|
||||
_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 && !webPortalActive) {
|
||||
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 +906,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 +1164,10 @@ boolean WiFiManager::process(){
|
||||
#endif
|
||||
|
||||
processScan();
|
||||
processPortalConnect();
|
||||
processStationController();
|
||||
if (!_stationCandidateFromPortal) {
|
||||
processPortalConnect();
|
||||
}
|
||||
|
||||
if(webPortalActive || configPortalActive){
|
||||
// if timed out or abort, break
|
||||
@@ -704,7 +1215,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 +1316,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 +1376,7 @@ bool WiFiManager::shutdownConfigPortal(){
|
||||
}
|
||||
|
||||
resetAsyncScan(true);
|
||||
releaseScanResultStorage();
|
||||
|
||||
if(!configPortalActive) return false;
|
||||
|
||||
@@ -1446,6 +1958,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 +2616,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 +2702,7 @@ void WiFiManager::portalSetPageSetupVisible(bool visible) {
|
||||
_portalPages.setupVisible = visible;
|
||||
}
|
||||
|
||||
|
||||
void WiFiManager::portalSetActionEraseVisible(bool visible) {
|
||||
_portalActions.eraseVisible = visible;
|
||||
}
|
||||
@@ -2227,17 +2807,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
|
||||
|
||||
@@ -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();
|
||||
@@ -890,6 +939,11 @@ void WiFiManagerHandlers::handleUpdating(AsyncWebServerRequest *request, String
|
||||
}
|
||||
|
||||
#ifdef ESP8266
|
||||
// ESPAsyncWebServer invokes this upload callback from the ESP8266 SYS
|
||||
// context. The core's default Updater mode yields around flash erases
|
||||
// and writes, but yield() panics from that context. Tell the core this
|
||||
// upload is asynchronous before the first Update call.
|
||||
Update.runAsync(true);
|
||||
WiFiUDP::stopAll();
|
||||
uint32_t maxSketchSpace = (ESP.getFreeSketchSpace() - 0x1000) & 0xFFFFF000;
|
||||
#elif defined(ESP32)
|
||||
@@ -954,9 +1008,15 @@ void WiFiManagerHandlers::handleUpdateDone(AsyncWebServerRequest *request) {
|
||||
return;
|
||||
}
|
||||
|
||||
// AsyncWebServer queues this response and closes the connection only after
|
||||
// it has completed the response. Restarting from this request callback—or
|
||||
// merely waiting a guessed interval—can still tear down that TCP exchange.
|
||||
// Schedule through process() after this particular response disconnects.
|
||||
request->onDisconnect([this]() {
|
||||
_wm->_rebootScheduled = true;
|
||||
_wm->_rebootTime = millis() + _wm->REBOOT_DELAY_MS;
|
||||
});
|
||||
sendApiJson(request, 200, jsonApiOtaUpdateSuccess());
|
||||
delay(1000);
|
||||
ESP.restart();
|
||||
}
|
||||
|
||||
void WiFiManagerHandlers::sendApiJson(AsyncWebServerRequest *request, int code, const String& json) {
|
||||
@@ -1013,14 +1073,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 +1100,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 +1252,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 +1341,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 +1413,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;
|
||||
@@ -1463,4 +1615,3 @@ void WiFiManagerHandlers::handleApiPortalExit(AsyncWebServerRequest *request) {
|
||||
}
|
||||
|
||||
#endif // defined(ESP8266) || defined(ESP32)
|
||||
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "WiFiManager",
|
||||
"version": "3.0.6",
|
||||
"version": "3.2.5",
|
||||
"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
-5
@@ -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.1
|
||||
ESP32Async/ESPAsyncTCP@2.0.0
|
||||
|
||||
[env:esp32]
|
||||
platform = espressif32@6.13.0
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/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.1
|
||||
ESP32Async/AsyncTCP@^3.4.9
|
||||
|
||||
; Optional: compile tests with DFTE logs bridged into WiFiManager::log (see README)
|
||||
[env:esp8266_dfte_log]
|
||||
@@ -43,4 +45,3 @@ extends = env:esp8266
|
||||
build_flags =
|
||||
${env:esp8266.build_flags}
|
||||
-DWM_DFTE_LOGGING
|
||||
|
||||
|
||||
Executable
+46
@@ -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."
|
||||
Executable
+68
@@ -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
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
failed=0
|
||||
|
||||
check_cpp_fence_scope() {
|
||||
local markdown="$1"
|
||||
awk '
|
||||
function brace_delta(line, copy) {
|
||||
copy = line
|
||||
return gsub(/\{/, "{", copy) - gsub(/\}/, "}", copy)
|
||||
}
|
||||
/^```cpp[[:space:]]*$/ { in_cpp = 1; depth = 0; next }
|
||||
in_cpp && /^```[[:space:]]*$/ { in_cpp = 0; next }
|
||||
in_cpp {
|
||||
line = $0
|
||||
sub(/^[[:space:]]+/, "", line)
|
||||
if (depth == 0 &&
|
||||
(line ~ /^(if|for|while|switch)[[:space:]]*\(/ ||
|
||||
line ~ /^[A-Za-z_][A-Za-z0-9_:]*::[A-Za-z0-9_]+[[:space:]]*\(/ ||
|
||||
line ~ /^[A-Za-z_][A-Za-z0-9_]*\./ ||
|
||||
line ~ /^[A-Za-z_][A-Za-z0-9_]*[[:space:]]*\(/)) {
|
||||
printf "%s:%d: C++ expression appears at namespace scope; wrap it in a function.\n", FILENAME, FNR > "/dev/stderr"
|
||||
failed = 1
|
||||
}
|
||||
depth += brace_delta($0)
|
||||
}
|
||||
END { exit failed }
|
||||
' "$markdown"
|
||||
}
|
||||
|
||||
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"
|
||||
check_cpp_fence_scope "$file" || failed=1
|
||||
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
|
||||
|
||||
check_readme_media() {
|
||||
local asset="$1"
|
||||
local expected_type="$2"
|
||||
local max_bytes="$3"
|
||||
local path="$root/docs/assets/readme/$asset"
|
||||
if [[ ! -s "$path" ]]; then
|
||||
printf 'Missing README media asset: %s\n' "docs/assets/readme/$asset" >&2
|
||||
failed=1
|
||||
return
|
||||
fi
|
||||
if [[ "$(file --brief --mime-type "$path")" != "$expected_type" ]]; then
|
||||
printf 'Unexpected README media type: %s\n' "docs/assets/readme/$asset" >&2
|
||||
failed=1
|
||||
fi
|
||||
if (( $(wc -c < "$path") > max_bytes )); then
|
||||
printf 'README media exceeds its size limit: %s\n' "docs/assets/readme/$asset" >&2
|
||||
failed=1
|
||||
fi
|
||||
}
|
||||
|
||||
check_readme_media portal-tour.gif image/gif $((2 * 1024 * 1024))
|
||||
check_readme_media portal-overview.png image/png $((1024 * 1024))
|
||||
check_readme_media portal-wifi-settings.png image/png $((1024 * 1024))
|
||||
|
||||
if [[ -n "$(git -C "$root" ls-files -- 'artifacts/readme-media/**')" ]]; then
|
||||
printf 'Ignored README media artifacts must not be tracked.\n' >&2
|
||||
failed=1
|
||||
fi
|
||||
|
||||
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"
|
||||
@@ -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"
|
||||
@@ -11,6 +11,8 @@ tag="${1:-}"
|
||||
[[ "${2:-}" == "" || "${2:-}" == "--tag" ]] || usage
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=tools/lib/platformio.sh
|
||||
source "$root/tools/lib/platformio.sh"
|
||||
version="${tag#v}"
|
||||
manifest_version="$(sed -n 's/.*"version": "\([^"]*\)".*/\1/p' "$root/library.json" | head -n 1)"
|
||||
|
||||
@@ -19,6 +21,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 +34,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"
|
||||
wm_pio pkg pack "$root" --output "$package_dir/package.tar.gz" >/dev/null
|
||||
echo "Validated release metadata and PlatformIO package for $tag"
|
||||
|
||||
if [[ "${2:-}" == "--tag" ]]; then
|
||||
git -C "$root" diff --quiet
|
||||
|
||||
Executable
+28
@@ -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"
|
||||
Executable
+176
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Keep standalone compilation predictable on laptops and shared workstations.
|
||||
# A developer may explicitly raise this for an isolated local diagnosis.
|
||||
export PLATFORMIO_RUN_JOBS="${PLATFORMIO_RUN_JOBS:-2}"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE' >&2
|
||||
Usage:
|
||||
./scripts/test.sh compile --platform esp8266|esp32
|
||||
./scripts/test.sh unity --platform esp8266|esp32
|
||||
./scripts/test.sh examples --platform esp8266|esp32
|
||||
./scripts/test.sh ota-fixtures --platform esp8266|esp32
|
||||
./scripts/test.sh hardware --platform esp8266|esp32 --port /dev/serial/by-id/...
|
||||
USAGE
|
||||
exit 2
|
||||
}
|
||||
|
||||
mode="${1:-}"
|
||||
[[ "$mode" == "compile" || "$mode" == "unity" || "$mode" == "examples" || "$mode" == "ota-fixtures" || "$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
|
||||
|
||||
case "$platform" in
|
||||
esp8266|esp32) environment="$platform" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
[[ "$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)"
|
||||
# shellcheck source=tools/lib/platformio.sh
|
||||
source "$root/tools/lib/platformio.sh"
|
||||
# shellcheck source=tools/lib/ota-fixture-identity.sh
|
||||
source "$root/tools/lib/ota-fixture-identity.sh"
|
||||
|
||||
pio_for_platform() {
|
||||
if [[ "$platform" != "esp32" ]]; then
|
||||
wm_pio "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
# Keep the maintained Core 3.3.11 package form in a persistent project
|
||||
# cache shared by the maintained framework repositories. It is never
|
||||
# cleared by this script and avoids stale global package metadata selecting
|
||||
# an incompatible uploader without redownloading this same pinned graph.
|
||||
local core_dir packages_dir cache_dir
|
||||
core_dir="${WIFIMANAGER_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11}"
|
||||
packages_dir="${WIFIMANAGER_PLATFORMIO_PACKAGES_DIR:-$core_dir/packages}"
|
||||
cache_dir="${WIFIMANAGER_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
|
||||
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
|
||||
PLATFORMIO_CORE_DIR="$core_dir" PLATFORMIO_PACKAGES_DIR="$packages_dir" \
|
||||
PLATFORMIO_CACHE_DIR="$cache_dir" wm_pio "$@"
|
||||
}
|
||||
|
||||
assert_ota_fixture_pair() {
|
||||
local firmware_a="$1" firmware_b="$2" firmware size capacity
|
||||
[[ -s "$firmware_a" && -s "$firmware_b" ]] || {
|
||||
echo "Portal OTA fixture build did not produce both A and B images." >&2
|
||||
return 1
|
||||
}
|
||||
if cmp -s "$firmware_a" "$firmware_b"; then
|
||||
echo "Portal OTA fixture A and B are identical." >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$platform" == "esp32" ]]; then
|
||||
capacity=$((0x1F0000))
|
||||
for firmware in "$firmware_a" "$firmware_b"; do
|
||||
size="$(wc -c < "$firmware" | tr -d '[:space:]')"
|
||||
(( size <= capacity )) || {
|
||||
echo "ESP32 OTA fixture $(basename "$firmware") is $size bytes; it exceeds the $capacity-byte app slot." >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "$mode" == "hardware" ]]; then
|
||||
# Unity uses only the named serial device; it does not own a portal AP or
|
||||
# secondary adapter, so it may run beside an unrelated station test.
|
||||
# shellcheck source=tools/lib/harness-locks.sh
|
||||
source "$root/tools/lib/harness-locks.sh"
|
||||
wm_harness_lock_serial_port "$port"
|
||||
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_for_platform run -d "$example" -e "$environment" </dev/null
|
||||
done
|
||||
echo "WiFiManager examples compile check passed for $platform"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$mode" == "ota-fixtures" ]]; then
|
||||
fixture_environment="${platform}_ota"
|
||||
if [[ "$platform" == "esp32" ]]; then
|
||||
"$root/tools/check-ota-partitions.sh"
|
||||
fi
|
||||
|
||||
# The A/B marker is the only changed source input. Capture each resulting
|
||||
# binary before rebuilding the same platform environment so CI proves the
|
||||
# update images differ without paying for duplicate dependency builds.
|
||||
fixture_dir="$(mktemp -d "${TMPDIR:-/tmp}/wifimanager-ota-fixtures.XXXXXX")"
|
||||
chmod 700 "$fixture_dir"
|
||||
export WIFIMANAGER_OTA_IDENTITY_DIR="$fixture_dir/identity"
|
||||
fixture_build_dir="$fixture_dir/build/$fixture_environment"
|
||||
wm_lock_ota_fixture_environment "$fixture_environment"
|
||||
wm_write_ota_fixture_identity A
|
||||
fixture_a="$fixture_dir/ota-a.bin"
|
||||
fixture_b="$fixture_dir/ota-b.bin"
|
||||
cleanup_ota_fixture_build() {
|
||||
wm_remove_ota_fixture_identity
|
||||
rm -rf -- "$fixture_dir"
|
||||
}
|
||||
on_ota_fixture_build_signal() {
|
||||
local status="$1"
|
||||
# Remove the generated identity before leaving. EXIT will call the
|
||||
# same idempotent cleanup once more, which is intentional.
|
||||
trap - HUP INT TERM
|
||||
cleanup_ota_fixture_build
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup_ota_fixture_build EXIT
|
||||
trap 'on_ota_fixture_build_signal 129' HUP
|
||||
trap 'on_ota_fixture_build_signal 130' INT
|
||||
trap 'on_ota_fixture_build_signal 143' TERM
|
||||
|
||||
PLATFORMIO_BUILD_DIR="$fixture_dir/build" \
|
||||
pio_for_platform run -d "$root/test/portal-harness" -e "$fixture_environment" </dev/null
|
||||
install -m 600 "$fixture_build_dir/firmware.bin" "$fixture_a"
|
||||
wm_write_ota_fixture_identity B
|
||||
PLATFORMIO_BUILD_DIR="$fixture_dir/build" \
|
||||
pio_for_platform run -d "$root/test/portal-harness" -e "$fixture_environment" </dev/null
|
||||
install -m 600 "$fixture_build_dir/firmware.bin" "$fixture_b"
|
||||
assert_ota_fixture_pair "$fixture_a" "$fixture_b"
|
||||
echo "WiFiManager portal OTA fixture compile check passed for $platform"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$mode" == "unity" ]]; then
|
||||
# Compile WiFiManager's own fixtures without a board. This is separate
|
||||
# from the clean-consumer fixture, which protects manifest resolution.
|
||||
pio_for_platform test -d "$root" -e "$environment" --filter test_wifimanager \
|
||||
--without-uploading --without-testing
|
||||
echo "WiFiManager Unity 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_for_platform 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
|
||||
|
||||
pio_for_platform run -d "$root/test/compile-project" -e "$environment"
|
||||
|
||||
echo "WiFiManager consumer compile check passed for $platform"
|
||||
@@ -20,11 +20,13 @@ lib_deps =
|
||||
|
||||
[env:esp32]
|
||||
extends = common
|
||||
platform = espressif32@6.13.0
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# 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 Test Harness ESP8266`; the ESP32 SSID is `WM Test Harness 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.
|
||||
|
||||
NetworkManager authority is a host prerequisite, not a fixture secret. A GUI
|
||||
Polkit session may authorize the adapter directly; a headless/SSH invocation
|
||||
validates sudo before flashing, then elevates only the generated portal
|
||||
connection actions. Leave the runner itself unprivileged so its private state
|
||||
and browser artifacts remain owned by the developer. See
|
||||
[`docs/TESTING.md`](../../docs/TESTING.md#networkmanager-authorization) for
|
||||
the `WM_NMCLI_AUTH` options.
|
||||
|
||||
## A/B portal OTA fixture
|
||||
|
||||
The physical portal HTTP OTA test harness uses one `*_ota` environment per
|
||||
platform. It writes a harness-only A/B identity header to an ignored, owner-only
|
||||
directory unique to that run before each build, so PlatformIO reuses dependency
|
||||
objects while the fixture-only `/api/test/firmware-marker` endpoint still proves
|
||||
the newly booted image. The test harness requires the real form's successful response, its automatic
|
||||
restart, and two fresh B-marker responses. Passive serial capture is retained
|
||||
for failure diagnosis, but a product log-message wording change cannot turn a
|
||||
successful A-to-B update into a failed test.
|
||||
|
||||
```bash
|
||||
./tools/portal-hardware ota \
|
||||
--platform esp32 \
|
||||
--port /dev/serial/by-id/... \
|
||||
--client-interface wlx...
|
||||
```
|
||||
|
||||
ESP8266 explicitly uses `eagle.flash.4m1m.ld`. ESP32 uses the tracked two-slot
|
||||
`partitions/esp32_ota_4m_no_fs.csv` layout on the maintained Arduino-ESP32
|
||||
3.3.11 fixture lane. Both are 4 MB layouts. The runner builds and preserves A
|
||||
and B before it touches the board, validates the matching ESP32 slots (or the
|
||||
live ESP8266 updater capacity), then erases the explicitly selected test board
|
||||
before serial-flashing A. A successful run leaves B installed in the
|
||||
portal-only fixture. It requires Python PySerial and retains a passive,
|
||||
no-reset `serial-ota.log` in the private run artifact directory for both
|
||||
success and failure diagnosis.
|
||||
@@ -0,0 +1,12 @@
|
||||
# 4 MB ESP32 portal-harness OTA test layout.
|
||||
#
|
||||
# The fixture compiles portal assets into the application image, so it uses no
|
||||
# filesystem. app0 and app1 are equal 0x1F0000-byte slots. The portal OTA
|
||||
# runner validates B against that inactive-slot size before it flashes A or
|
||||
# submits the browser upload.
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x5000,
|
||||
otadata, data, ota, 0xe000, 0x2000,
|
||||
app0, app, ota_0, 0x10000, 0x1F0000,
|
||||
app1, app, ota_1, 0x200000, 0x1F0000,
|
||||
coredump, data, coredump,0x3F0000, 0x10000,
|
||||
|
@@ -0,0 +1,50 @@
|
||||
[platformio]
|
||||
default_envs = esp8266
|
||||
|
||||
[common]
|
||||
framework = arduino
|
||||
lib_ldf_mode = deep+
|
||||
lib_deps =
|
||||
WiFiManager=symlink://../..
|
||||
build_flags =
|
||||
extra_scripts =
|
||||
pre:tools/ota_fixture_input.py
|
||||
|
||||
[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/55.03.311/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
|
||||
|
||||
; The OTA test harness writes its A/B marker into an ignored header included
|
||||
; only by the portal fixture. One environment per platform keeps the explicit
|
||||
; update layout while allowing the A-to-B rebuild to reuse every library object.
|
||||
[env:esp8266_ota]
|
||||
extends = env:esp8266
|
||||
board_build.ldscript = eagle.flash.4m1m.ld
|
||||
build_flags =
|
||||
${env:esp8266.build_flags}
|
||||
${common.build_flags}
|
||||
-DWM_PORTAL_OTA_TEST=1
|
||||
|
||||
[env:esp32_ota]
|
||||
extends = env:esp32
|
||||
board_build.partitions = partitions/esp32_ota_4m_no_fs.csv
|
||||
build_flags =
|
||||
${env:esp32.build_flags}
|
||||
${common.build_flags}
|
||||
-DWM_PORTAL_OTA_TEST=1
|
||||
@@ -0,0 +1,137 @@
|
||||
#include <Arduino.h>
|
||||
#include <WiFiManager.h>
|
||||
|
||||
namespace {
|
||||
|
||||
// These markers belong only to the portal OTA fixture. The hardware runner
|
||||
// writes the ignored header immediately before each A/B build, so the marker
|
||||
// is compiled into firmware rather than saved in WiFiManager settings.
|
||||
#if defined(WM_PORTAL_OTA_TEST)
|
||||
#include <ota_fixture_identity.h>
|
||||
#ifndef WM_OTA_FIXTURE_IMAGE
|
||||
#error "Portal OTA fixture identity is missing."
|
||||
#endif
|
||||
// PlatformIO releases the serial port only after the upload-triggered reset.
|
||||
// The physical OTA test harness then attaches passively, so give it the same
|
||||
// explicit window as the DeviceFramework A/B fixtures before boot evidence or
|
||||
// portal work begins. Normal portal test-harness builds keep the short delay.
|
||||
constexpr unsigned long kSerialMonitorAttachDelayMs = 5000UL;
|
||||
#else
|
||||
#define WM_OTA_FIXTURE_IMAGE "portal-harness"
|
||||
constexpr unsigned long kSerialMonitorAttachDelayMs = 300UL;
|
||||
#endif
|
||||
|
||||
#if defined(ESP8266)
|
||||
constexpr char kPortalSsid[] = "WM Test Harness ESP8266";
|
||||
#else
|
||||
constexpr char kPortalSsid[] = "WM Test Harness ESP32";
|
||||
#endif
|
||||
constexpr char kPortalPassword[] = "default1";
|
||||
|
||||
WiFiManager wifi;
|
||||
WiFiManagerParameter kInstallationLabel(
|
||||
"installation_label", "Installation label", "Harness fixture", 32);
|
||||
// Keep the characters from upstream issue #1863 in the portal fixture. The
|
||||
// browser test harness verifies this value through JSON, DOM rendering, save,
|
||||
// and a subsequent reload rather than relying on a string-only serializer
|
||||
// check.
|
||||
WiFiManagerParameter kEscapedValue(
|
||||
"escaped_value", "Escaped value", "7(f+4]2y3fsYTQt'Uhxc\"d\\<>&", 64);
|
||||
WiFiManagerParameter kMqttHost(
|
||||
"mqtt_host", "MQTT host", "broker.example.local", 64);
|
||||
WiFiManagerParameter kMqttPort(
|
||||
"mqtt_port", "MQTT port", "1883", 8);
|
||||
WiFiManagerParameter kDeviceRoom(
|
||||
"device_room", "Device room", "Workshop", 32);
|
||||
WiFiManagerParameter kSensorName(
|
||||
"sensor_name", "Sensor name", "Ambient temperature", 48);
|
||||
WiFiManagerParameter kTelemetryTopic(
|
||||
"telemetry_topic", "Telemetry topic", "sensors/ambient/temperature", 64);
|
||||
WiFiManagerParameter kTimezone(
|
||||
"timezone", "Timezone", "Australia/Brisbane", 48);
|
||||
WiFiManagerParameter kLatitude(
|
||||
"latitude", "Latitude", "-27.4698", 16);
|
||||
WiFiManagerParameter kLongitude(
|
||||
"longitude", "Longitude", "153.0251", 16);
|
||||
WiFiManagerParameter kFirmwareChannel(
|
||||
"firmware_channel", "Firmware channel", "stable", 16);
|
||||
WiFiManagerParameter kOwnerName(
|
||||
"owner_name", "Owner name", "Portal test harness", 48);
|
||||
WiFiManagerParameter kNotes(
|
||||
"notes", "Notes", "Thirteen-field rendering fixture", 64);
|
||||
|
||||
WiFiManagerParameter* const kPortalParameters[] = {
|
||||
&kInstallationLabel,
|
||||
&kEscapedValue,
|
||||
&kMqttHost,
|
||||
&kMqttPort,
|
||||
&kDeviceRoom,
|
||||
&kSensorName,
|
||||
&kTelemetryTopic,
|
||||
&kTimezone,
|
||||
&kLatitude,
|
||||
&kLongitude,
|
||||
&kFirmwareChannel,
|
||||
&kOwnerName,
|
||||
&kNotes,
|
||||
};
|
||||
|
||||
void registerOtaTestMarker() {
|
||||
// setWebServerCallback runs after WiFiManager creates its server and
|
||||
// before it registers built-in routes. This private fixture endpoint is
|
||||
// intentionally not a WiFiManager product API.
|
||||
wifi.setWebServerCallback([]() {
|
||||
AsyncWebServer* const server = wifi.getServer();
|
||||
if (server == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
server->on("/api/test/firmware-marker", HTTP_GET,
|
||||
[](AsyncWebServerRequest* request) {
|
||||
String response = F("{\"marker\":\"");
|
||||
response += WM_OTA_FIXTURE_IMAGE;
|
||||
response += F("\",\"freeSketchSpace\":");
|
||||
response += String(ESP.getFreeSketchSpace());
|
||||
response += F("}");
|
||||
request->send(200, "application/json", response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(kSerialMonitorAttachDelayMs);
|
||||
// The physical HTTP OTA test harness records this immutable marker before
|
||||
// and after its browser upload. It cannot be faked by saved portal values
|
||||
// or an HTTP response from a stale image.
|
||||
Serial.print(F("WiFiManager portal OTA fixture image: "));
|
||||
Serial.println(WM_OTA_FIXTURE_IMAGE);
|
||||
|
||||
// This fixture must be independent of whichever sketch was previously
|
||||
// flashed to the board. Clear saved station credentials before starting
|
||||
// the portal so browser artifacts always show the unconfigured flow.
|
||||
wifi.resetSettings();
|
||||
|
||||
// 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));
|
||||
registerOtaTestMarker();
|
||||
// Keep custom parameters on their own native Save parameters page. This
|
||||
// lets the browser test harness exercise a parameter-only submit without
|
||||
// starting a station connection as part of the regression test.
|
||||
wifi.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage);
|
||||
for (auto* parameter : kPortalParameters) {
|
||||
wifi.portalAddParameter(parameter);
|
||||
}
|
||||
wifi.startConfigPortal(kPortalSsid, kPortalPassword);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
wifi.process();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Add the run-owned A/B identity directory to this portal test-harness build."""
|
||||
|
||||
import os
|
||||
|
||||
from SCons.Script import Exit
|
||||
|
||||
Import("env")
|
||||
|
||||
if env["PIOENV"].endswith("_ota"):
|
||||
identity_dir = os.environ.get("WIFIMANAGER_OTA_IDENTITY_DIR")
|
||||
if not identity_dir:
|
||||
print(
|
||||
"WIFIMANAGER_OTA_IDENTITY_DIR is required; "
|
||||
"run this fixture through its named test harness."
|
||||
)
|
||||
Exit(1)
|
||||
|
||||
identity_header = os.path.join(identity_dir, "ota_fixture_identity.h")
|
||||
if not os.path.isfile(identity_header):
|
||||
print(f"OTA fixture identity header is missing: {identity_header}")
|
||||
Exit(1)
|
||||
|
||||
env.Append(CPPPATH=[identity_dir])
|
||||
@@ -0,0 +1,5 @@
|
||||
# Ignored local credentials for the optional station handoff test.
|
||||
# The runner copies only these two values into a private one-run file mounted
|
||||
# read-only into Docker; any other local env entries are not passed through.
|
||||
WIFI_SSID=replace-me
|
||||
WIFI_PASSWORD=replace-me
|
||||
@@ -13,8 +13,11 @@ 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_profile_portal_candidate_does_not_take_legacy_empty_ssid_path),
|
||||
TEST_ENTRY(test_api_info_json_shape),
|
||||
TEST_ENTRY(test_api_params_json_shape),
|
||||
TEST_ENTRY(test_api_params_json_escapes_custom_parameter_value),
|
||||
TEST_ENTRY(test_api_status_json_shape),
|
||||
|
||||
// Configuration tests
|
||||
@@ -36,9 +39,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_schema_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),
|
||||
@@ -101,10 +106,12 @@ TestCase tests[] = {
|
||||
TEST_ENTRY(test_scan_cancels_when_connect_pending),
|
||||
TEST_ENTRY(test_scan_cancels_when_lifecycle_blocked),
|
||||
TEST_ENTRY(test_scan_generation_invalidated_on_reset),
|
||||
TEST_ENTRY(test_scan_restart_interval_is_platform_appropriate),
|
||||
|
||||
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 +132,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);
|
||||
@@ -177,4 +186,3 @@ void loop() {
|
||||
begun = false; // avoid repeating
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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_schema_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,18 +107,23 @@ 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_scan_restart_interval_is_platform_appropriate();
|
||||
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_profile_portal_candidate_does_not_take_legacy_empty_ssid_path();
|
||||
void test_api_info_json_shape();
|
||||
void test_api_params_json_shape();
|
||||
void test_api_params_json_escapes_custom_parameter_value();
|
||||
void test_api_status_json_shape();
|
||||
|
||||
// State transition tests
|
||||
@@ -138,6 +145,7 @@ 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,47 @@ 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_profile_portal_candidate_does_not_take_legacy_empty_ssid_path() {
|
||||
Serial.println("[TEST] Testing profile portal candidate state ownership...");
|
||||
|
||||
WiFiManager wm;
|
||||
#ifdef UNIT_TEST
|
||||
WiFiManagerStationProfiles candidate;
|
||||
candidate.slots[0].enabled = true;
|
||||
snprintf(candidate.slots[0].ssid, sizeof(candidate.slots[0].ssid), "%s", "candidate-network");
|
||||
wm.wmTestSetWebPortalActive(true);
|
||||
TEST_ASSERT_TRUE(wm.startStationCandidate(candidate));
|
||||
wm.process();
|
||||
TEST_ASSERT_EQUAL_MESSAGE(
|
||||
WiFiManager::WM_CP_CONNECT_WAITING,
|
||||
wm.getConfigPortalConnectState(),
|
||||
"A web-portal profile candidate must not be treated as the legacy empty-SSID save path"
|
||||
);
|
||||
#else
|
||||
TEST_IGNORE_MESSAGE("UNIT_TEST helpers unavailable");
|
||||
#endif
|
||||
|
||||
Serial.println("[TEST] Profile portal candidate state ownership test completed successfully");
|
||||
}
|
||||
|
||||
void test_api_info_json_shape() {
|
||||
Serial.println("[TEST] Testing /api/info JSON shape...");
|
||||
|
||||
@@ -126,6 +167,24 @@ void test_api_params_json_shape() {
|
||||
Serial.println("[TEST] API params JSON shape test completed successfully");
|
||||
}
|
||||
|
||||
void test_api_params_json_escapes_custom_parameter_value() {
|
||||
Serial.println("[TEST] Testing escaped custom parameter JSON value...");
|
||||
|
||||
constexpr char value[] = "7(f+4]2y3fsYTQt'Uhxc\"d\\<>&";
|
||||
WiFiManager wm;
|
||||
WiFiManagerHandlers handlers(&wm);
|
||||
WiFiManagerParameter field("escaped_value", "Escaped value", value,
|
||||
static_cast<int>(sizeof(value) - 1));
|
||||
wm.portalAddParameter(&field);
|
||||
|
||||
const String json = handlers.buildApiParamsGetJson();
|
||||
// JSON must retain apostrophes, angle brackets and ampersands as data,
|
||||
// while escaping the quote and backslash that delimit a JSON string.
|
||||
TEST_ASSERT_NOT_NULL(strstr(json.c_str(), "7(f+4]2y3fsYTQt'Uhxc\\\"d\\\\<>&"));
|
||||
|
||||
Serial.println("[TEST] Escaped custom parameter JSON value test completed successfully");
|
||||
}
|
||||
|
||||
void test_api_status_json_shape() {
|
||||
Serial.println("[TEST] Testing /api/status 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_schema_v3() {
|
||||
Serial.println("[TEST] Testing bootstrap JSON v3 schema...");
|
||||
|
||||
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\"")));
|
||||
@@ -130,7 +143,7 @@ void test_bootstrap_json_contract_v2() {
|
||||
TEST_ASSERT_EQUAL(-1, j.indexOf(F("\"portalTimeoutSecondsRemaining\":0")));
|
||||
wm.wmTestSetPortalActive(false);
|
||||
|
||||
Serial.println("[TEST] Bootstrap JSON v2 contract test completed successfully");
|
||||
Serial.println("[TEST] Bootstrap JSON v3 schema test completed successfully");
|
||||
}
|
||||
|
||||
void test_bootstrap_json_snapshot_consistency() {
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -150,3 +150,24 @@ void test_scan_generation_invalidated_on_reset() {
|
||||
Serial.println("[TEST] Scan generation invalidation on reset test completed successfully");
|
||||
}
|
||||
|
||||
void test_scan_restart_interval_is_platform_appropriate() {
|
||||
Serial.println("[TEST] Testing platform scan restart interval...");
|
||||
|
||||
WiFiManager wm;
|
||||
|
||||
#ifdef UNIT_TEST
|
||||
#ifdef ESP32
|
||||
TEST_ASSERT_EQUAL_UINT32_MESSAGE(
|
||||
5000, wm.wmTestGetScanRestartIntervalMs(),
|
||||
"ESP32 must wait for the radio to settle after a completed scan");
|
||||
#else
|
||||
TEST_ASSERT_EQUAL_UINT32_MESSAGE(
|
||||
2000, wm.wmTestGetScanRestartIntervalMs(),
|
||||
"ESP8266 keeps the established responsive scan restart interval");
|
||||
#endif
|
||||
#else
|
||||
TEST_IGNORE_MESSAGE("UNIT_TEST helpers unavailable");
|
||||
#endif
|
||||
|
||||
Serial.println("[TEST] Platform scan restart interval test completed successfully");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# The harness image needs only checked-in browser-test sources. Keep local
|
||||
# artifacts and any credential-shaped file out even when a developer chooses
|
||||
# an output path inside this directory.
|
||||
node_modules
|
||||
artifacts
|
||||
**/artifacts
|
||||
*.env
|
||||
**/*.env
|
||||
.portal-station.*
|
||||
**/.portal-station.*
|
||||
@@ -0,0 +1,19 @@
|
||||
ARG PLAYWRIGHT_VERSION=1.63.0
|
||||
FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble AS portal-harness
|
||||
|
||||
ARG PLAYWRIGHT_VERSION
|
||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
WORKDIR /work
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts && \
|
||||
[ "$(node -p "require('@playwright/test/package.json').version")" = "$PLAYWRIGHT_VERSION" ]
|
||||
COPY . ./
|
||||
RUN chmod 755 /work/run-portal-harness.sh /work/render-readme-media.sh
|
||||
CMD ["/work/run-portal-harness.sh"]
|
||||
|
||||
FROM portal-harness AS media
|
||||
USER root
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
USER pwuser
|
||||
@@ -0,0 +1,21 @@
|
||||
# Portal test-harness 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.
|
||||
|
||||
`compose.ota.yaml` is an overlay used only by `portal-hardware ota`. It mounts
|
||||
the already-built B firmware read-only and enables the A/B browser test
|
||||
harness. The ordinary portal test harness never receives a firmware artifact.
|
||||
|
||||
`compose.station.yaml` is used only by the opt-in station-handoff command. The
|
||||
host runner stages only `WIFI_SSID` and `WIFI_PASSWORD` in a mode-600 temporary
|
||||
file instead of mounting the developer's complete environment file. That mode
|
||||
disables Playwright screenshots, video, and tracing because request bodies can
|
||||
contain the local password.
|
||||
@@ -0,0 +1,11 @@
|
||||
# OTA-only overlay. The normal portal test harness neither builds nor mounts a
|
||||
# firmware image. portal-hardware supplies this absolute path after it has
|
||||
# built B and copied it into the run's private artifact directory.
|
||||
services:
|
||||
portal-harness:
|
||||
environment:
|
||||
PORTAL_OTA_FIRMWARE: /firmware/portal-ota-b.bin
|
||||
PORTAL_OTA_INITIAL_MARKER: ${PORTAL_OTA_INITIAL_MARKER:-A}
|
||||
PORTAL_OTA_EXPECTED_MARKER: ${PORTAL_OTA_EXPECTED_MARKER:-B}
|
||||
volumes:
|
||||
- ${PORTAL_OTA_FIRMWARE_HOST:?OTA firmware path is required}:/firmware/portal-ota-b.bin:ro
|
||||
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
portal-harness:
|
||||
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
|
||||
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
portal-harness:
|
||||
build:
|
||||
# Keep Docker's build context limited to the browser test harness. A
|
||||
# developer may point --station-env or --output anywhere in the repo;
|
||||
# neither source credentials nor the generated two-key file may cross
|
||||
# the host-to-Docker boundary during image build.
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: ${PORTAL_HARNESS_DOCKER_TARGET:-portal-harness}
|
||||
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}
|
||||
PORTAL_CUSTOM_PARAMETER_STRESS: ${PORTAL_CUSTOM_PARAMETER_STRESS:-0}
|
||||
PORTAL_PLATFORM: ${PORTAL_PLATFORM:-}
|
||||
PORTAL_CAPTURE_README_MEDIA: ${PORTAL_CAPTURE_README_MEDIA:-0}
|
||||
PORTAL_TEST_FILE: ${PORTAL_TEST_FILE:-}
|
||||
ARTIFACT_DIR: /artifacts
|
||||
volumes:
|
||||
- ${PORTAL_ARTIFACT_DIR:?portal artifact directory is required}:/artifacts
|
||||
Generated
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "wifimanager-portal-harness",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wifimanager-portal-harness",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "wifimanager-portal-harness",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Configuration for the browser half of the portal test harness.
|
||||
const path = require('path');
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
const artifactDir = process.env.ARTIFACT_DIR || path.join(__dirname, 'artifacts');
|
||||
// The optional station-handoff test submits real local Wi-Fi credentials. The
|
||||
// runner mounts only its generated two-key file, but Playwright traces can
|
||||
// retain request bodies, so leave no screenshots, video, or trace behind for
|
||||
// that one opt-in credential-bearing mode.
|
||||
const hasStationCredentials = Boolean(process.env.PORTAL_STATION_ENV);
|
||||
|
||||
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: hasStationCredentials ? 'off' : 'only-on-failure',
|
||||
trace: hasStationCredentials ? 'off' : 'retain-on-failure',
|
||||
video: hasStationCredentials ? 'off' : 'retain-on-failure',
|
||||
},
|
||||
});
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# README-media renderer used by the portal test harness.
|
||||
set -euo pipefail
|
||||
|
||||
artifact_dir="${ARTIFACT_DIR:?ARTIFACT_DIR is required}"
|
||||
media_dir="$artifact_dir/readme-media"
|
||||
source_video="$media_dir/raw/portal-tour.webm"
|
||||
target_gif="$media_dir/portal-tour.gif"
|
||||
|
||||
[[ -s "$source_video" ]] || {
|
||||
echo "README media video was not recorded: $source_video" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ffmpeg -hide_banner -loglevel error -y -i "$source_video" \
|
||||
-filter_complex '[0:v]setpts=1.25*PTS,fps=6,scale=720:-2:flags=lanczos,split[a][b];[a]palettegen=max_colors=128[p];[b][p]paletteuse' \
|
||||
-loop 0 "$target_gif"
|
||||
|
||||
[[ -s "$target_gif" ]] || {
|
||||
echo "README media GIF was not rendered: $target_gif" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
duration="$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$target_gif")"
|
||||
awk -v duration="$duration" 'BEGIN { exit !(duration >= 4 && duration <= 30) }' || {
|
||||
echo "README media GIF duration is outside the 4–30 second review range: $duration" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
max_bytes=$((2 * 1024 * 1024))
|
||||
(( $(wc -c < "$target_gif") <= max_bytes )) || {
|
||||
echo "README media GIF exceeds its 2 MiB documentation budget: $target_gif" >&2
|
||||
exit 1
|
||||
}
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
playwright_args=(test --config /work/playwright.config.js)
|
||||
if [[ -n "${PORTAL_TEST_FILE:-}" ]]; then
|
||||
# OTA is a destructive, time-bounded board test harness. Run only its browser
|
||||
# spec instead of allowing ordinary portal tests to consume its AP window.
|
||||
playwright_args+=("$PORTAL_TEST_FILE")
|
||||
fi
|
||||
/work/node_modules/.bin/playwright "${playwright_args[@]}"
|
||||
|
||||
if [[ "${PORTAL_CAPTURE_README_MEDIA:-0}" == "1" ]]; then
|
||||
/work/render-readme-media.sh
|
||||
fi
|
||||
@@ -0,0 +1,209 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const hasStationCredentials = Boolean(process.env.PORTAL_STATION_ENV);
|
||||
|
||||
async function saveDiagnosticScreenshot(page, target) {
|
||||
// The optional station hand-off submits real local credentials. Playwright's
|
||||
// automatic artifacts are disabled in that mode, and explicit screenshots
|
||||
// must honor the same boundary.
|
||||
if (!hasStationCredentials) {
|
||||
await page.screenshot({ path: target, fullPage: true });
|
||||
}
|
||||
}
|
||||
|
||||
const fixtureParameters = [
|
||||
{ id: 'installation_label', value: 'Harness fixture' },
|
||||
{ id: 'escaped_value', value: "7(f+4]2y3fsYTQt'Uhxc\"d\\<>&" },
|
||||
{ id: 'mqtt_host', value: 'broker.example.local' },
|
||||
{ id: 'mqtt_port', value: '1883' },
|
||||
{ id: 'device_room', value: 'Workshop' },
|
||||
{ id: 'sensor_name', value: 'Ambient temperature' },
|
||||
{ id: 'telemetry_topic', value: 'sensors/ambient/temperature' },
|
||||
{ id: 'timezone', value: 'Australia/Brisbane' },
|
||||
{ id: 'latitude', value: '-27.4698' },
|
||||
{ id: 'longitude', value: '153.0251' },
|
||||
{ id: 'firmware_channel', value: 'stable' },
|
||||
{ id: 'owner_name', value: 'Portal test harness' },
|
||||
{ id: 'notes', value: 'Thirteen-field rendering fixture' },
|
||||
];
|
||||
const escapedUpdatedValue = "updated 'quote\" slash\\<>&";
|
||||
|
||||
async function json(response) {
|
||||
return JSON.parse(await response.text());
|
||||
}
|
||||
|
||||
function expectFixtureParameters(payload, expected = fixtureParameters) {
|
||||
expect(payload.params).toHaveLength(expected.length);
|
||||
const byId = new Map(payload.params.map((field) => [field.id, field]));
|
||||
for (const { id, value } of expected) {
|
||||
expect(byId.get(id), `missing fixture field ${id}`).toBeDefined();
|
||||
expect(byId.get(id).value, `unexpected value for ${id}`).toBe(value);
|
||||
}
|
||||
}
|
||||
|
||||
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 test harness', () => {
|
||||
test('serves API, retains all thirteen 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);
|
||||
expect(bootstrap.layout.paramsLocation).toBe('setup');
|
||||
|
||||
const metaResponse = await request.get('/api/wifi/meta');
|
||||
expect(metaResponse.ok()).toBeTruthy();
|
||||
const meta = await json(metaResponse);
|
||||
// The fixture intentionally uses a separate Settings page. Its custom
|
||||
// parameters are therefore served by /api/params rather than duplicated
|
||||
// in the Wi-Fi form metadata.
|
||||
expect(meta.params).toHaveLength(0);
|
||||
|
||||
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();
|
||||
|
||||
// Issue #1787 was intermittent and memory-sensitive on ESP8266. Fetch
|
||||
// the complete API response repeatedly so ordinary portal runs verify
|
||||
// every field and value without opting into the longer browser soak.
|
||||
for (let attempt = 0; attempt < 4; attempt += 1) {
|
||||
const paramsResponse = await request.get('/api/params');
|
||||
expect(paramsResponse.ok(), `parameter fetch ${attempt + 1}`).toBeTruthy();
|
||||
expectFixtureParameters(await json(paramsResponse));
|
||||
}
|
||||
|
||||
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 saveDiagnosticScreenshot(page, `${process.env.ARTIFACT_DIR}/portal-overview-desktop.png`);
|
||||
|
||||
await page.goto('/#/wifi', { waitUntil: 'networkidle' });
|
||||
await expect(page.locator('#wm-refresh-scan')).toBeVisible();
|
||||
await page.goto('/#/setup', { waitUntil: 'networkidle' });
|
||||
await expect(page.locator('#wm-param-form input')).toHaveCount(fixtureParameters.length);
|
||||
await expect(page.locator('#wm-f-installation_label')).toBeVisible();
|
||||
await expect(page.locator('#wm-f-escaped_value')).toHaveValue(fixtureParameters[1].value);
|
||||
await page.locator('#wm-f-installation_label').fill('Browser verified');
|
||||
await saveDiagnosticScreenshot(page, `${process.env.ARTIFACT_DIR}/portal-wifi-desktop.png`);
|
||||
|
||||
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 saveDiagnosticScreenshot(mobilePage, `${process.env.ARTIFACT_DIR}/portal-device-mobile.png`);
|
||||
|
||||
await mobile.close();
|
||||
await desktop.close();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('round-trips quotes, apostrophes, backslashes, and HTML-sensitive values', async ({ page, request }) => {
|
||||
test.skip(process.env.PORTAL_BROWSER_MODE === 'skip', 'Browser checks were explicitly skipped.');
|
||||
|
||||
await page.goto('/#/setup', { waitUntil: 'networkidle' });
|
||||
await expect(page.locator('#wm-param-form input')).toHaveCount(fixtureParameters.length);
|
||||
const escapedInput = page.locator('#wm-f-escaped_value');
|
||||
await expect(escapedInput).toHaveValue(fixtureParameters[1].value);
|
||||
|
||||
await escapedInput.fill(escapedUpdatedValue);
|
||||
const saveResponse = page.waitForResponse((response) => (
|
||||
response.url().endsWith('/api/params/save')
|
||||
&& response.request().method() === 'POST'
|
||||
));
|
||||
await page.locator('#wm-param-form button[type="submit"]').click();
|
||||
expect((await saveResponse).ok()).toBeTruthy();
|
||||
|
||||
const paramsResponse = await request.get('/api/params');
|
||||
expect(paramsResponse.ok()).toBeTruthy();
|
||||
const expected = fixtureParameters.map((field) => (
|
||||
field.id === 'escaped_value' ? { ...field, value: escapedUpdatedValue } : field
|
||||
));
|
||||
expectFixtureParameters(await json(paramsResponse), expected);
|
||||
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
await expect(page.locator('#wm-f-escaped_value')).toHaveValue(escapedUpdatedValue);
|
||||
});
|
||||
|
||||
test('ESP8266 repeatedly renders all thirteen custom parameters', async ({ page, request }) => {
|
||||
test.skip(process.env.PORTAL_CUSTOM_PARAMETER_STRESS !== '1',
|
||||
'ESP8266 custom-parameter browser stress was not requested.');
|
||||
test.skip(process.env.PORTAL_PLATFORM !== 'esp8266',
|
||||
'Custom-parameter browser stress is scoped to ESP8266.');
|
||||
|
||||
const expected = fixtureParameters.map((field) => (
|
||||
field.id === 'escaped_value' ? { ...field, value: escapedUpdatedValue } : field
|
||||
));
|
||||
const resetResponse = await request.post('/api/params/save', {
|
||||
form: Object.fromEntries(expected.map(({ id, value }) => [id, value])),
|
||||
});
|
||||
expect(resetResponse.ok()).toBeTruthy();
|
||||
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const paramsResponse = await request.get('/api/params');
|
||||
expect(paramsResponse.ok(), `API fetch ${attempt + 1}`).toBeTruthy();
|
||||
const params = await json(paramsResponse);
|
||||
expectFixtureParameters(params, expected);
|
||||
|
||||
await page.goto('/#/setup', { waitUntil: 'networkidle' });
|
||||
await expect(page.locator('#wm-param-form input')).toHaveCount(expected.length);
|
||||
for (const { id, value } of expected) {
|
||||
await expect(page.locator(`#wm-f-${id}`), `${id}, render ${attempt + 1}`).toHaveValue(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
const firmware = process.env.PORTAL_OTA_FIRMWARE;
|
||||
const initialMarker = process.env.PORTAL_OTA_INITIAL_MARKER || 'A';
|
||||
const expectedMarker = process.env.PORTAL_OTA_EXPECTED_MARKER || 'B';
|
||||
|
||||
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
async function getMarker(request) {
|
||||
const response = await request.get('/api/test/firmware-marker', { timeout: 4_000 });
|
||||
if (!response.ok()) {
|
||||
throw new Error(`marker endpoint returned HTTP ${response.status()}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function waitForMarker(request, expected, timeout = 75_000) {
|
||||
let observed;
|
||||
await expect.poll(async () => {
|
||||
try {
|
||||
observed = await getMarker(request);
|
||||
return observed.marker;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, {
|
||||
timeout,
|
||||
intervals: [250, 500, 1_000, 1_000],
|
||||
}).toBe(expected);
|
||||
return observed;
|
||||
}
|
||||
|
||||
async function requireRestartOutage(request) {
|
||||
const deadline = Date.now() + 25_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await request.get('/api/test/firmware-marker', { timeout: 1_000 });
|
||||
if (!response.ok()) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
throw new Error('The portal never became unavailable after a successful OTA response.');
|
||||
}
|
||||
|
||||
function waitForOtaResponse(page) {
|
||||
// `waitForResponse()` alone waits until the enclosing test timeout when an
|
||||
// embedded server resets the upload connection. Treat that as an immediate
|
||||
// transport failure so a hardware artifact names the real fault instead of
|
||||
// implying that the rendered form never submitted.
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('Timed out waiting for the portal OTA POST /u response.'));
|
||||
}, 90_000);
|
||||
|
||||
const isOtaRequest = (request) => {
|
||||
const requestPath = new URL(request.url()).pathname;
|
||||
return requestPath === '/u' && request.method() === 'POST';
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
page.off('response', onResponse);
|
||||
page.off('requestfailed', onRequestFailed);
|
||||
};
|
||||
const onResponse = (response) => {
|
||||
if (!isOtaRequest(response.request())) {
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
resolve(response);
|
||||
};
|
||||
const onRequestFailed = (request) => {
|
||||
if (!isOtaRequest(request)) {
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
const failure = request.failure();
|
||||
reject(new Error(`Portal OTA POST /u failed before a response: ${failure ? failure.errorText : 'unknown error'}`));
|
||||
};
|
||||
|
||||
page.on('response', onResponse);
|
||||
page.on('requestfailed', onRequestFailed);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('portal HTTP OTA test harness', () => {
|
||||
test('uploads B through the rendered portal form, requires automatic reboot, and observes B twice', async ({ page, request }) => {
|
||||
test.skip(!firmware, 'OTA firmware is mounted only for portal-hardware ota.');
|
||||
// Initial portal availability, an observed outage, and two fresh B
|
||||
// responses each have their own bounded waits. Keep the overall budget
|
||||
// larger than their sum so a valid slow reassociation is not killed by
|
||||
// Playwright before the fixture test harness has concluded.
|
||||
test.setTimeout(300_000);
|
||||
|
||||
const initial = await waitForMarker(request, initialMarker);
|
||||
expect(initial.freeSketchSpace).toEqual(expect.any(Number));
|
||||
expect(initial.freeSketchSpace).toBeGreaterThan(0);
|
||||
|
||||
// This deliberately uses the rendered UI and its multipart XHR instead of
|
||||
// posting directly to /u. It therefore covers the real file control,
|
||||
// submit handling, success JSON, and restart presentation together.
|
||||
await page.goto('/#/update', { waitUntil: 'networkidle' });
|
||||
const input = page.locator('#wm-ota-file');
|
||||
await expect(input).toBeVisible();
|
||||
await input.setInputFiles(firmware);
|
||||
|
||||
const updateResponse = waitForOtaResponse(page);
|
||||
await page.locator('#wm-ota-form button[type="submit"]').click();
|
||||
|
||||
const response = await updateResponse;
|
||||
expect(response.status()).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({ ok: true });
|
||||
|
||||
// A manual reset is never issued here. Observing the outage is what proves
|
||||
// WiFiManager's Update.end(true) path restarted the board on its own.
|
||||
await requireRestartOutage(request);
|
||||
const first = await waitForMarker(request, expectedMarker);
|
||||
expect(first.freeSketchSpace).toEqual(expect.any(Number));
|
||||
await sleep(1_000);
|
||||
const second = await waitForMarker(request, expectedMarker);
|
||||
expect(second.freeSketchSpace).toEqual(expect.any(Number));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
function mediaPath(...parts) {
|
||||
const root = process.env.ARTIFACT_DIR || '/artifacts';
|
||||
const target = path.join(root, 'readme-media', ...parts);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
return target;
|
||||
}
|
||||
|
||||
async function waitForCompletedScan(request) {
|
||||
await expect.poll(async () => {
|
||||
try {
|
||||
const response = await request.get('/api/wifi/scan-status');
|
||||
if (!response.ok()) return false;
|
||||
const result = JSON.parse(await response.text());
|
||||
return result.state === 'complete' && result.results_valid && result.count > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { timeout: 45_000, intervals: [500, 800, 1_000] }).toBe(true);
|
||||
}
|
||||
|
||||
test.describe('WiFiManager README media', () => {
|
||||
test.skip(process.env.PORTAL_CAPTURE_README_MEDIA !== '1', 'README capture was not requested.');
|
||||
test.skip(Boolean(process.env.PORTAL_STATION_ENV),
|
||||
'README recording is unavailable when a station hand-off carries local credentials.');
|
||||
|
||||
test('records an approved ESP32 portal tour', async ({ browser, request }) => {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 720, height: 900 },
|
||||
recordVideo: {
|
||||
dir: mediaPath('raw'),
|
||||
size: { width: 720, height: 900 },
|
||||
},
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text());
|
||||
});
|
||||
|
||||
const video = page.video();
|
||||
await page.goto('/', { waitUntil: 'networkidle' });
|
||||
await expect(page.locator('#wm-reset-portal-timeout')).toBeVisible();
|
||||
// These pauses exist only in the README recording. The ordinary test harness
|
||||
// remains timing-focused; this tour needs readable stable states.
|
||||
await page.waitForTimeout(1200);
|
||||
await page.screenshot({ path: mediaPath('portal-overview.png'), fullPage: true });
|
||||
|
||||
await page.locator('a[href="#/wifi"]').click();
|
||||
await expect(page.locator('#wm-refresh-scan')).toBeVisible();
|
||||
await waitForCompletedScan(request);
|
||||
await expect(page.locator('#wm-scan-results .wm-scan-row').first()).toBeVisible();
|
||||
await page.locator('#wm-f-installation_label').fill('Workshop sensor');
|
||||
await page.waitForTimeout(1200);
|
||||
await page.screenshot({ path: mediaPath('portal-wifi-settings.png'), fullPage: true });
|
||||
|
||||
await page.locator('#wm-refresh-scan').click();
|
||||
await expect(page.locator('#wm-wifi-scan-overlay')).toBeVisible();
|
||||
await page.waitForTimeout(1200);
|
||||
await context.close();
|
||||
|
||||
const source = await video.path();
|
||||
fs.renameSync(source, mediaPath('raw', 'portal-tour.webm'));
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// Optional LAN handoff exercised by the portal test harness.
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Passively retain serial evidence for a physical portal OTA upload.
|
||||
|
||||
The recorder deliberately opens the ESP USB-UART with both modem-control lines
|
||||
inactive. It never resets the target: the calling runner has already flashed
|
||||
and booted fixture A before this process attaches.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
import serial
|
||||
|
||||
|
||||
_stop_requested = False
|
||||
IMMEDIATE_EMPTY_READ_SECONDS = 0.01
|
||||
EMPTY_READ_BACKOFF_INITIAL_SECONDS = 0.01
|
||||
EMPTY_READ_BACKOFF_MAX_SECONDS = 0.25
|
||||
|
||||
|
||||
def request_stop(_signum, _frame):
|
||||
"""Let the read loop finish promptly after a normal runner cleanup."""
|
||||
global _stop_requested
|
||||
_stop_requested = True
|
||||
|
||||
|
||||
def open_capture_port(port):
|
||||
"""Open a UART without PySerial's default reset-causing line assertion."""
|
||||
serial_port = serial.Serial(
|
||||
port=None,
|
||||
baudrate=115200,
|
||||
timeout=0.25,
|
||||
rtscts=False,
|
||||
dsrdtr=False,
|
||||
)
|
||||
serial_port.dtr = False
|
||||
serial_port.rts = False
|
||||
serial_port.port = port
|
||||
serial_port.open()
|
||||
return serial_port
|
||||
|
||||
|
||||
def write_ready(path):
|
||||
"""Publish readiness only after the passive serial port is open."""
|
||||
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
os.write(descriptor, b"ready\n")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def capture(
|
||||
port,
|
||||
output,
|
||||
ready_file,
|
||||
should_stop=None,
|
||||
deadline_seconds=None,
|
||||
clock=time.monotonic,
|
||||
sleep=time.sleep,
|
||||
):
|
||||
"""Append serial bytes until terminated by the owning hardware runner."""
|
||||
global _stop_requested
|
||||
_stop_requested = False
|
||||
if should_stop is None:
|
||||
should_stop = lambda: _stop_requested
|
||||
|
||||
try:
|
||||
with (
|
||||
open_capture_port(port) as serial_port,
|
||||
open(output, "ab", buffering=0) as output_file,
|
||||
):
|
||||
os.fchmod(output_file.fileno(), 0o600)
|
||||
write_ready(ready_file)
|
||||
deadline = None
|
||||
if deadline_seconds is not None:
|
||||
deadline = clock() + deadline_seconds
|
||||
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
|
||||
while not should_stop():
|
||||
if deadline is not None and clock() >= deadline:
|
||||
print("Passive serial capture reached its deadline.", file=sys.stderr)
|
||||
return 2
|
||||
read_started = clock()
|
||||
data = serial_port.read(4096)
|
||||
if data:
|
||||
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
|
||||
output_file.write(data)
|
||||
continue
|
||||
|
||||
# A real serial port blocks for its configured timeout. A
|
||||
# broken or detached backend can return an empty read
|
||||
# immediately. Back off only in that pathological case, and
|
||||
# reset after real data, so the recorder cannot become a
|
||||
# CPU-bound loop without penalising normal serial timeouts.
|
||||
if clock() - read_started < IMMEDIATE_EMPTY_READ_SECONDS:
|
||||
sleep(empty_read_backoff)
|
||||
empty_read_backoff = min(
|
||||
empty_read_backoff * 2,
|
||||
EMPTY_READ_BACKOFF_MAX_SECONDS,
|
||||
)
|
||||
else:
|
||||
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
|
||||
except (OSError, serial.SerialException) as error:
|
||||
print(f"Passive serial capture failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--port", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--ready-file", required=True)
|
||||
parser.add_argument("--deadline-seconds", type=float)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.deadline_seconds is not None and args.deadline_seconds <= 0:
|
||||
parser.error("--deadline-seconds must be greater than zero")
|
||||
|
||||
signal.signal(signal.SIGTERM, request_stop)
|
||||
signal.signal(signal.SIGINT, request_stop)
|
||||
return capture(args.port, args.output, args.ready_file, deadline_seconds=args.deadline_seconds)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# Verify the tracked ESP32 OTA table used by the portal A/B fixtures. Keep the
|
||||
# check independent of PlatformIO so CI rejects a layout regression before it
|
||||
# downloads a framework or a hardware runner erases a board.
|
||||
set -euo pipefail
|
||||
|
||||
project_dir="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
table="${1:-$project_dir/test/portal-harness/partitions/esp32_ota_4m_no_fs.csv}"
|
||||
|
||||
[[ $# -le 1 && -r "$table" ]] || {
|
||||
echo "Usage: $0 [partition-table.csv]" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
partition_row() {
|
||||
local name="$1" type="$2" subtype="$3"
|
||||
awk -F, -v name="$name" -v type="$type" -v subtype="$subtype" '
|
||||
function trim(value) {
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
||||
return value
|
||||
}
|
||||
/^[[:space:]]*#/ || NF < 5 { next }
|
||||
trim($1) == name && trim($2) == type && trim($3) == subtype {
|
||||
print trim($1) "," trim($2) "," trim($3) "," trim($4) "," trim($5)
|
||||
}
|
||||
' "$table"
|
||||
}
|
||||
|
||||
require_row() {
|
||||
local name="$1" type="$2" subtype="$3" offset="$4" size="$5" actual expected
|
||||
expected="$name,$type,$subtype,$offset,$size"
|
||||
actual="$(partition_row "$name" "$type" "$subtype")"
|
||||
[[ "$actual" == "$expected" ]] || {
|
||||
echo "Expected exactly this OTA partition row: $expected" >&2
|
||||
echo "Found: ${actual:-<none>}" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
require_row nvs data nvs 0x9000 0x5000
|
||||
require_row otadata data ota 0xe000 0x2000
|
||||
require_row app0 app ota_0 0x10000 0x1F0000
|
||||
require_row app1 app ota_1 0x200000 0x1F0000
|
||||
|
||||
app_count="$(awk -F, '
|
||||
function trim(value) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", value); return value }
|
||||
/^[[:space:]]*#/ || NF < 5 { next }
|
||||
trim($2) == "app" { count++ }
|
||||
END { print count + 0 }
|
||||
' "$table")"
|
||||
[[ "$app_count" == 2 ]] || {
|
||||
echo "OTA layout must contain exactly two application partitions, found $app_count." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if awk -F, '
|
||||
function trim(value) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", value); return value }
|
||||
/^[[:space:]]*#/ || NF < 5 { next }
|
||||
trim($2) == "data" && trim($3) ~ /^(spiffs|littlefs|fat)$/ { found = 1 }
|
||||
END { exit found ? 0 : 1 }
|
||||
' "$table"; then
|
||||
echo "OTA test layout must not reserve a filesystem partition." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ESP32 OTA partition test-harness check passed: $table"
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Resource locks shared by WiFiManager and DeviceFramework physical test harnesses.
|
||||
#
|
||||
# The protocol deliberately uses a stable, non-secret path and hash input so
|
||||
# standalone checkouts still coordinate when they use the same host resources.
|
||||
|
||||
declare -A WM_HARNESS_LOCK_FDS=()
|
||||
|
||||
wm_harness_lock_root() {
|
||||
local runtime_root
|
||||
if [[ -n "${ARDUINO_TEST_HARNESS_LOCK_DIR:-}" ]]; then
|
||||
printf '%s\n' "$ARDUINO_TEST_HARNESS_LOCK_DIR"
|
||||
return 0
|
||||
fi
|
||||
runtime_root="${XDG_RUNTIME_DIR:-}"
|
||||
if [[ -n "$runtime_root" && -d "$runtime_root" && -w "$runtime_root" ]]; then
|
||||
printf '%s/arduino-framework-test-harness-locks\n' "$runtime_root"
|
||||
else
|
||||
printf '%s/arduino-framework-test-harness-locks\n' "${TMPDIR:-/tmp}"
|
||||
fi
|
||||
}
|
||||
|
||||
wm_harness_lock_resource() {
|
||||
local label="$1" resource="$2" lock_root digest lock_file lock_fd
|
||||
[[ -n "$label" && -n "$resource" ]] || {
|
||||
echo "A test-harness resource lock needs a label and key." >&2
|
||||
return 2
|
||||
}
|
||||
[[ -n "${WM_HARNESS_LOCK_FDS[$resource]:-}" ]] && return 0
|
||||
command -v flock >/dev/null 2>&1 || {
|
||||
echo "flock is required to protect test-harness resources." >&2
|
||||
return 1
|
||||
}
|
||||
command -v sha256sum >/dev/null 2>&1 || {
|
||||
echo "sha256sum is required to name test-harness resource locks." >&2
|
||||
return 1
|
||||
}
|
||||
lock_root="$(wm_harness_lock_root)"
|
||||
install -d -m 700 "$lock_root"
|
||||
digest="$(printf '%s' "$resource" | sha256sum | awk '{print $1}')"
|
||||
lock_file="$lock_root/${digest}.lock"
|
||||
exec {lock_fd}>"$lock_file"
|
||||
if ! flock -n "$lock_fd"; then
|
||||
printf "Cannot start: %s is already in use by another local test-harness process.\n" "$label" >&2
|
||||
return 1
|
||||
fi
|
||||
WM_HARNESS_LOCK_FDS["$resource"]="$lock_fd"
|
||||
}
|
||||
|
||||
wm_harness_lock_serial_port() {
|
||||
local port="$1" canonical
|
||||
canonical="$(readlink -f -- "$port" 2>/dev/null || printf '%s' "$port")"
|
||||
wm_harness_lock_resource "serial port $canonical" "serial-port:$canonical"
|
||||
}
|
||||
|
||||
wm_harness_lock_portal_network() {
|
||||
wm_harness_lock_resource "the 192.168.4.0/24 portal network" "portal-network:192.168.4.0/24"
|
||||
}
|
||||
|
||||
wm_harness_lock_wifi_adapter() {
|
||||
local interface="$1"
|
||||
wm_harness_lock_resource "Wi-Fi adapter $interface" "wifi-adapter:$interface"
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generated A/B identity used only by WiFiManager's portal OTA test harness.
|
||||
#
|
||||
# Keeping the identity in a tiny header lets PlatformIO reuse the one platform
|
||||
# environment's library objects. The header is ignored and removed on every
|
||||
# normal test-harness exit; it is never part of a user sketch or release.
|
||||
|
||||
declare -A WM_OTA_FIXTURE_LOCK_FDS=()
|
||||
|
||||
wm_lock_ota_fixture_environment() {
|
||||
local environment="$1" lock_root lock_file lock_fd
|
||||
[[ "$environment" =~ ^[A-Za-z0-9_-]+$ ]] || {
|
||||
echo "Unsafe OTA fixture environment name: $environment" >&2
|
||||
return 2
|
||||
}
|
||||
[[ -n "${WM_OTA_FIXTURE_LOCK_FDS[$environment]:-}" ]] && return 0
|
||||
command -v flock >/dev/null 2>&1 || {
|
||||
echo "flock is required to protect PlatformIO OTA fixture inputs." >&2
|
||||
return 1
|
||||
}
|
||||
lock_root="$root/test/portal-harness/.pio/harness-locks"
|
||||
install -d -m 700 "$lock_root"
|
||||
lock_file="$lock_root/${environment}.lock"
|
||||
exec {lock_fd}>"$lock_file"
|
||||
if ! flock -n "$lock_fd"; then
|
||||
echo "OTA fixture environment '$environment' is already in use by another local test-harness run." >&2
|
||||
return 1
|
||||
fi
|
||||
WM_OTA_FIXTURE_LOCK_FDS["$environment"]="$lock_fd"
|
||||
}
|
||||
|
||||
wm_ota_fixture_identity_dir() {
|
||||
[[ -n "${WIFIMANAGER_OTA_IDENTITY_DIR:-}" ]] || {
|
||||
echo "WIFIMANAGER_OTA_IDENTITY_DIR must be set before writing an OTA fixture identity." >&2
|
||||
return 2
|
||||
}
|
||||
printf '%s\n' "$WIFIMANAGER_OTA_IDENTITY_DIR"
|
||||
}
|
||||
|
||||
wm_ota_fixture_identity_header() {
|
||||
printf '%s/ota_fixture_identity.h\n' "$(wm_ota_fixture_identity_dir)"
|
||||
}
|
||||
|
||||
wm_write_ota_fixture_identity() {
|
||||
local image="$1" directory header temporary
|
||||
case "$image" in
|
||||
A|B) ;;
|
||||
*)
|
||||
echo "WiFiManager OTA fixture identity must be A or B." >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
|
||||
directory="$(wm_ota_fixture_identity_dir)" || return
|
||||
header="$(wm_ota_fixture_identity_header)"
|
||||
install -d -m 700 "$directory"
|
||||
temporary="$(mktemp "$directory/ota_fixture_identity.XXXXXX")"
|
||||
chmod 600 "$temporary"
|
||||
printf '%s\n' \
|
||||
'#pragma once' \
|
||||
'// Generated by the WiFiManager OTA test harness. Do not commit.' \
|
||||
"#define WM_OTA_FIXTURE_IMAGE \"$image\"" \
|
||||
> "$temporary"
|
||||
mv -f -- "$temporary" "$header"
|
||||
}
|
||||
|
||||
wm_remove_ota_fixture_identity() {
|
||||
local directory header
|
||||
[[ -n "${WIFIMANAGER_OTA_IDENTITY_DIR:-}" ]] || return 0
|
||||
directory="$(wm_ota_fixture_identity_dir)" || return
|
||||
header="$(wm_ota_fixture_identity_header)"
|
||||
rm -f -- "$header"
|
||||
rmdir -- "$directory" 2>/dev/null || true
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# PlatformIO discovery shared by WiFiManager's local test and hardware runners.
|
||||
# Non-interactive shells, including an SSH hardware lab session, do not always
|
||||
# include PlatformIO's standard virtual environment in PATH.
|
||||
|
||||
wm_pio_executable() {
|
||||
local executable
|
||||
if [[ -n "${WIFIMANAGER_PIO_EXECUTABLE:-}" ]]; then
|
||||
executable="$WIFIMANAGER_PIO_EXECUTABLE"
|
||||
else
|
||||
executable="$(command -v pio 2>/dev/null || true)"
|
||||
if [[ -z "$executable" && -x "$HOME/.platformio/penv/bin/pio" ]]; then
|
||||
executable="$HOME/.platformio/penv/bin/pio"
|
||||
fi
|
||||
fi
|
||||
[[ -n "$executable" && -x "$executable" ]] || {
|
||||
echo "PlatformIO is required; install it or set WIFIMANAGER_PIO_EXECUTABLE." >&2
|
||||
return 1
|
||||
}
|
||||
printf '%s\n' "$executable"
|
||||
}
|
||||
|
||||
wm_pio_available() {
|
||||
wm_pio_executable >/dev/null
|
||||
}
|
||||
|
||||
wm_pio() {
|
||||
local executable
|
||||
executable="$(wm_pio_executable)" || return
|
||||
"$executable" "$@"
|
||||
}
|
||||
Executable
+454
@@ -0,0 +1,454 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck source=tools/lib/harness-locks.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/harness-locks.sh"
|
||||
# Shared host-side helpers for the WiFiManager portal hardware test harness.
|
||||
# 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_nmcli_permission() {
|
||||
local permission="$1"
|
||||
awk -F: -v permission="$permission" '$1 == permission { print $2; exit }' \
|
||||
<<<"${WM_NMCLI_PERMISSIONS:-}"
|
||||
}
|
||||
|
||||
wm_prepare_networkmanager_authorization() {
|
||||
# A GUI Polkit agent can authorize direct nmcli actions. An SSH/headless
|
||||
# shell has no such agent on many Linux hosts, even for a sudo-capable
|
||||
# developer. Resolve that host-side boundary before an erase/upload, then
|
||||
# use one fixed command path rather than suppressing an unauthorized scan
|
||||
# and later misreporting an SSID timeout.
|
||||
local permission value direct=yes
|
||||
case "${WM_NMCLI_AUTH:-auto}" in
|
||||
auto|direct|sudo) ;;
|
||||
*)
|
||||
echo 'WM_NMCLI_AUTH must be auto, direct, or sudo.' >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
[[ "${WM_NMCLI_AUTH_READY:-no}" == yes ]] && return 0
|
||||
|
||||
WM_NMCLI_PERMISSIONS="$(nmcli -t -f PERMISSION,VALUE general permissions 2>/dev/null || true)"
|
||||
for permission in \
|
||||
org.freedesktop.NetworkManager.wifi.scan \
|
||||
org.freedesktop.NetworkManager.network-control \
|
||||
org.freedesktop.NetworkManager.settings.modify.system; do
|
||||
value="$(wm_nmcli_permission "$permission")"
|
||||
[[ "$value" == yes ]] || direct=no
|
||||
done
|
||||
|
||||
case "${WM_NMCLI_AUTH:-auto}" in
|
||||
direct)
|
||||
WM_NMCLI_MODE=direct
|
||||
;;
|
||||
sudo)
|
||||
WM_NMCLI_MODE=sudo
|
||||
;;
|
||||
auto)
|
||||
if [[ "$direct" == yes ]]; then
|
||||
WM_NMCLI_MODE=direct
|
||||
# A session D-Bus socket is common over SSH but does not itself
|
||||
# provide a graphical Polkit agent, so require an actual display.
|
||||
elif [[ -z "${DISPLAY:-}" && -z "${WAYLAND_DISPLAY:-}" ]]; then
|
||||
WM_NMCLI_MODE=sudo
|
||||
else
|
||||
# Give a graphical Polkit agent the chance to authorize the
|
||||
# action. A failure is reported verbatim by wm_nmcli.
|
||||
WM_NMCLI_MODE=direct
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$WM_NMCLI_MODE" == sudo ]]; then
|
||||
command -v sudo >/dev/null 2>&1 || {
|
||||
echo 'NetworkManager requires authorization, but sudo is unavailable. Use a graphical Polkit session or install/configure sudo.' >&2
|
||||
return 1
|
||||
}
|
||||
echo 'NetworkManager requires scoped authorization for the named portal adapter; validating sudo before the board is flashed.' >&2
|
||||
sudo -v || {
|
||||
echo 'Could not validate sudo for the scoped NetworkManager portal actions.' >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
WM_NMCLI_AUTH_READY=yes
|
||||
export WM_NMCLI_MODE WM_NMCLI_AUTH_READY WM_NMCLI_PERMISSIONS
|
||||
}
|
||||
|
||||
wm_report_networkmanager_authorization() {
|
||||
local permission value direct=yes
|
||||
WM_NMCLI_PERMISSIONS="$(nmcli -t -f PERMISSION,VALUE general permissions 2>/dev/null || true)"
|
||||
for permission in \
|
||||
org.freedesktop.NetworkManager.wifi.scan \
|
||||
org.freedesktop.NetworkManager.network-control \
|
||||
org.freedesktop.NetworkManager.settings.modify.system; do
|
||||
value="$(wm_nmcli_permission "$permission")"
|
||||
[[ "$value" == yes ]] || direct=no
|
||||
done
|
||||
if [[ "$direct" == yes ]]; then
|
||||
echo 'NetworkManager portal authorization: direct.'
|
||||
elif [[ -z "${DISPLAY:-}" && -z "${WAYLAND_DISPLAY:-}" ]]; then
|
||||
echo 'NetworkManager portal authorization: scoped sudo will be requested before a portal command flashes the board.'
|
||||
else
|
||||
echo 'NetworkManager portal authorization: graphical Polkit may authorize actions; set WM_NMCLI_AUTH=sudo to use scoped sudo instead.'
|
||||
fi
|
||||
}
|
||||
|
||||
wm_nmcli() {
|
||||
# Only this small set of nmcli calls is elevated when the preflight selects
|
||||
# sudo. The runner, artifacts, and session record remain owned by the
|
||||
# invoking developer; every mutating call still names the guarded adapter
|
||||
# or a generated temporary connection.
|
||||
if [[ "${WM_NMCLI_MODE:-direct}" == sudo ]]; then
|
||||
sudo -n true || {
|
||||
echo 'The sudo authorization for scoped NetworkManager actions expired; run sudo -v and retry the portal command.' >&2
|
||||
return 1
|
||||
}
|
||||
sudo -n -- nmcli "$@"
|
||||
else
|
||||
nmcli "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
wm_default_route_interface() {
|
||||
ip route show default 2>/dev/null | awk '/^default/{print $5; exit}'
|
||||
}
|
||||
|
||||
wm_acquire_hardware_lock() {
|
||||
# All ordinary ESP portals use this gateway/subnet. Keep portal commands
|
||||
# mutually exclusive even when they name different boards or adapters.
|
||||
wm_harness_lock_portal_network
|
||||
}
|
||||
|
||||
wm_require_client_adapter() {
|
||||
local interface="$1" allow_takeover="$2" default_interface device_type 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
|
||||
}
|
||||
# A non-default Ethernet, tunnel, or virtual interface can otherwise look
|
||||
# harmless here and reach board flashing before the first Wi-Fi scan
|
||||
# fails. Ask NetworkManager through the already-selected authorization
|
||||
# path, so a denied inspection cannot be mistaken for a usable adapter.
|
||||
if ! device_type="$(wm_nmcli -g GENERAL.TYPE device show "$interface" 2>/dev/null)"; then
|
||||
echo "NetworkManager could not determine the selected portal adapter type: $interface" >&2
|
||||
return 1
|
||||
fi
|
||||
[[ "$device_type" == "wifi" ]] || {
|
||||
echo "Client adapter is not Wi-Fi: $interface ($device_type)." >&2
|
||||
return 1
|
||||
}
|
||||
wm_harness_lock_wifi_adapter "$interface"
|
||||
if ! active_connection="$(wm_nmcli -g GENERAL.CONNECTION device show "$interface" 2>/dev/null)"; then
|
||||
echo "NetworkManager could not inspect the selected portal adapter: $interface" >&2
|
||||
return 1
|
||||
fi
|
||||
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 Test Harness ESP8266' ;;
|
||||
esp32) printf '%s\n' 'WM Test Harness ESP32' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
wm_wait_for_portal_ssid() {
|
||||
local interface="$1" ssid="$2" attempt advertised
|
||||
if ! wm_nmcli device wifi rescan ifname "$interface"; then
|
||||
echo "NetworkManager could not scan the selected portal adapter: $interface" >&2
|
||||
return 1
|
||||
fi
|
||||
for attempt in $(seq 1 45); do
|
||||
if ! advertised="$(wm_nmcli -t -f SSID device wifi list ifname "$interface")"; then
|
||||
echo "NetworkManager could not read Wi-Fi scan results from $interface." >&2
|
||||
return 1
|
||||
fi
|
||||
if grep -Fxq "$ssid" <<<"$advertised"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
if ! wm_nmcli device wifi rescan ifname "$interface"; then
|
||||
echo "NetworkManager could not refresh Wi-Fi scan results from $interface." >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
echo "Portal SSID not detected on $interface: $ssid" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wm_remove_connection_by_name() {
|
||||
local name="$1"
|
||||
[[ -n "$name" ]] || return 0
|
||||
wm_nmcli connection down "$name" >/dev/null 2>&1 || true
|
||||
if ! wm_nmcli connection delete "$name"; then
|
||||
# A pending recovery record can survive an uncatchable exit before
|
||||
# `connection add` ran. Only a successful complete listing which does
|
||||
# not contain this generated name proves that there is nothing left to
|
||||
# remove; an authorization or NetworkManager query failure must retain
|
||||
# the record for an explicit later `down` command.
|
||||
if wm_connection_name_is_absent "$name"; then
|
||||
return 0
|
||||
fi
|
||||
wm_report_portal_connection_cleanup_failure
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
wm_connection_name_is_absent() {
|
||||
local name="$1" names
|
||||
if ! names="$(wm_nmcli -t -f NAME connection show 2>/dev/null)"; then
|
||||
return 1
|
||||
fi
|
||||
! grep -Fxq -- "$name" <<<"$names"
|
||||
}
|
||||
|
||||
wm_connection_uuid_is_absent() {
|
||||
local uuid="$1" uuids
|
||||
if ! uuids="$(wm_nmcli -t -f UUID connection show 2>/dev/null)"; then
|
||||
return 1
|
||||
fi
|
||||
! grep -Fxq -- "$uuid" <<<"$uuids"
|
||||
}
|
||||
|
||||
wm_report_portal_connection_cleanup_failure() {
|
||||
echo 'Could not remove the temporary WiFiManager portal connection. Its recovery state was retained; restore NetworkManager authorization and run ./tools/portal-hardware down.' >&2
|
||||
}
|
||||
|
||||
wm_create_portal_connection() {
|
||||
local interface="$1" ssid="$2" password="$3" platform="${4:-}" reconnect_after_drop="${5:-no}" name uuid
|
||||
case "$reconnect_after_drop" in
|
||||
yes|no) ;;
|
||||
*)
|
||||
echo "Portal connection reconnect policy must be yes or no." >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
name="wifimanager-portal-${RANDOM}-$(date +%s)"
|
||||
# Publish the owned name before the first NetworkManager mutation. The
|
||||
# caller's signal trap can then remove it throughout the pending-to-active
|
||||
# state transition.
|
||||
WM_PORTAL_CONNECTION_NAME="$name"
|
||||
WM_PORTAL_CONNECTION_UUID=""
|
||||
WM_PORTAL_CONNECTION_OWNED=yes
|
||||
export WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME WM_PORTAL_CONNECTION_OWNED
|
||||
# A pending record is atomically committed before the first NetworkManager
|
||||
# mutation. If the host dies after that point, `down` can remove this
|
||||
# exact name even before NetworkManager's UUID has been obtained.
|
||||
if [[ -n "$platform" ]] && ! wm_write_state "$interface" "$platform" "" "$name"; then
|
||||
WM_PORTAL_CONNECTION_OWNED=no
|
||||
unset WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
|
||||
return 1
|
||||
fi
|
||||
wm_nmcli device disconnect "$interface" >/dev/null 2>&1 || true
|
||||
if ! wm_wait_for_portal_ssid "$interface" "$ssid"; then
|
||||
# No `connection add` has run on this path, so this process knows that
|
||||
# its pending record has no NetworkManager profile to remove. Clear it
|
||||
# directly rather than requiring a privileged delete of a profile that
|
||||
# cannot exist.
|
||||
if ! wm_clear_state; then
|
||||
wm_report_portal_connection_cleanup_failure
|
||||
return 1
|
||||
fi
|
||||
WM_PORTAL_CONNECTION_OWNED=no
|
||||
unset WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
|
||||
return 1
|
||||
fi
|
||||
if ! wm_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
|
||||
wm_cleanup_created_connection
|
||||
return 1
|
||||
fi
|
||||
# NetworkManager creates the UUID at `connection add`, before any later
|
||||
# configuration or association step. Capture it immediately so cleanup
|
||||
# has an exact identifier throughout the remaining critical section.
|
||||
uuid="$(wm_nmcli -g connection.uuid connection show "$name")"
|
||||
if [[ -z "$uuid" || "$uuid" == "--" ]]; then
|
||||
wm_cleanup_created_connection
|
||||
echo "NetworkManager did not return a UUID for the portal connection." >&2
|
||||
return 1
|
||||
fi
|
||||
WM_PORTAL_CONNECTION_UUID="$uuid"
|
||||
export WM_PORTAL_CONNECTION_UUID
|
||||
# Atomically replace the pending record with the exact UUID before
|
||||
# association. A normal signal trap removes it immediately; the durable
|
||||
# record also makes `down` useful after an uncatchable host termination.
|
||||
if [[ -n "$platform" ]] && ! wm_write_state "$interface" "$platform" "$uuid" "$name"; then
|
||||
wm_cleanup_created_connection
|
||||
return 1
|
||||
fi
|
||||
if ! wm_nmcli connection modify "$name" wifi-sec.key-mgmt wpa-psk wifi-sec.psk "$password"; then
|
||||
wm_cleanup_created_connection
|
||||
return 1
|
||||
fi
|
||||
# The ordinary portal runner must leave its disposable connection inert so
|
||||
# it cannot surprise a developer later. The OTA runner is different: the
|
||||
# board intentionally disappears after POST /u, then returns as the same
|
||||
# AP, so its one owned connection needs to reassociate autonomously for the
|
||||
# browser and host-side B checks. Cleanup still deletes this exact profile.
|
||||
if [[ "$reconnect_after_drop" == yes ]] && ! wm_nmcli connection modify "$name" connection.autoconnect yes; then
|
||||
wm_cleanup_created_connection
|
||||
return 1
|
||||
fi
|
||||
if ! wm_nmcli connection up "$name" ifname "$interface"; then
|
||||
wm_cleanup_created_connection
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
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:-}" name="${2:-}"
|
||||
[[ -n "$uuid" || -n "$name" ]] || return 0
|
||||
if [[ -n "$uuid" ]]; then
|
||||
wm_nmcli connection down uuid "$uuid" >/dev/null 2>&1 || true
|
||||
if ! wm_nmcli connection delete uuid "$uuid"; then
|
||||
# An active UUID means this runner did create a profile. Retain
|
||||
# the exact recovery state unless the same authorized
|
||||
# NetworkManager view positively proves another actor already
|
||||
# removed it. A failed listing (including an expired scoped sudo
|
||||
# ticket) is never treated as absence.
|
||||
if ! wm_connection_uuid_is_absent "$uuid"; then
|
||||
wm_report_portal_connection_cleanup_failure
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
elif [[ -n "$name" ]]; then
|
||||
wm_remove_connection_by_name "$name" || return 1
|
||||
fi
|
||||
}
|
||||
|
||||
wm_cleanup_created_connection() {
|
||||
# Only remove the connection this process named. This is intentionally
|
||||
# separate from `finish_portal_session`, which reads a retained state file
|
||||
# for an explicit later `down` command.
|
||||
[[ "${WM_PORTAL_CONNECTION_OWNED:-no}" == "yes" ]] || return 0
|
||||
wm_remove_connection "${WM_PORTAL_CONNECTION_UUID:-}" "${WM_PORTAL_CONNECTION_NAME:-}" || return 1
|
||||
if ! wm_clear_state; then
|
||||
echo 'The temporary WiFiManager portal connection was removed, but its recovery state could not be cleared.' >&2
|
||||
return 1
|
||||
fi
|
||||
WM_PORTAL_CONNECTION_OWNED=no
|
||||
unset WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
|
||||
}
|
||||
|
||||
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 temporary_file state
|
||||
root="$(wm_portal_state_root)"
|
||||
file="$(wm_state_file)"
|
||||
install -d -m 700 "$root"
|
||||
if [[ -n "$uuid" ]]; then
|
||||
state="active"
|
||||
else
|
||||
state="pending"
|
||||
fi
|
||||
temporary_file="$(mktemp "$root/.session.env.XXXXXX")" || return 1
|
||||
if ! {
|
||||
printf 'WM_PORTAL_STATE=%s\nWM_PORTAL_INTERFACE=%s\nWM_PORTAL_PLATFORM=%s\nWM_PORTAL_CONNECTION_UUID=%s\nWM_PORTAL_CONNECTION_NAME=%s\n' \
|
||||
"$state" "$interface" "$platform" "$uuid" "$name"
|
||||
} >"$temporary_file"; then
|
||||
rm -f "$temporary_file"
|
||||
return 1
|
||||
fi
|
||||
chmod 600 "$temporary_file"
|
||||
mv -f "$temporary_file" "$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=""
|
||||
WM_PORTAL_STATE=""
|
||||
while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
WM_PORTAL_STATE|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_NAME" ]] || {
|
||||
echo "Incomplete WiFiManager portal session state." >&2
|
||||
return 1
|
||||
}
|
||||
# State files written before this recovery format carried an exact UUID
|
||||
# but no phase. Continue to accept those retained sessions as active.
|
||||
if [[ -z "$WM_PORTAL_STATE" && -n "$WM_PORTAL_CONNECTION_UUID" ]]; then
|
||||
WM_PORTAL_STATE="active"
|
||||
fi
|
||||
case "$WM_PORTAL_STATE" in
|
||||
pending)
|
||||
[[ -z "$WM_PORTAL_CONNECTION_UUID" ]] || {
|
||||
echo "Invalid WiFiManager pending portal session state." >&2
|
||||
return 1
|
||||
}
|
||||
;;
|
||||
active)
|
||||
[[ -n "$WM_PORTAL_CONNECTION_UUID" ]] || {
|
||||
echo "Incomplete WiFiManager active portal session state." >&2
|
||||
return 1
|
||||
}
|
||||
;;
|
||||
*)
|
||||
echo "Invalid WiFiManager portal session state." >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
export WM_PORTAL_STATE 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"
|
||||
}
|
||||
Executable
+890
@@ -0,0 +1,890 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=tools/lib/platformio.sh
|
||||
source "$root/tools/lib/platformio.sh"
|
||||
# shellcheck source=tools/lib/portal-hardware-session.sh
|
||||
source "$root/tools/lib/portal-hardware-session.sh"
|
||||
# shellcheck source=tools/lib/ota-fixture-identity.sh
|
||||
source "$root/tools/lib/ota-fixture-identity.sh"
|
||||
|
||||
# Portal tests exclusively own their portal network, selected Wi-Fi adapter,
|
||||
# and named serial port. Avoid consuming every host core unless the developer
|
||||
# explicitly opts in.
|
||||
export PLATFORMIO_RUN_JOBS="${PLATFORMIO_RUN_JOBS:-2}"
|
||||
|
||||
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] \
|
||||
[--capture-readme-media] [--custom-parameter-stress]
|
||||
./tools/portal-hardware ota --platform esp8266|esp32 --port /dev/serial/by-id/... \
|
||||
--client-interface IFACE [--take-over-client-adapter] [--keep] \
|
||||
[--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`/`ota` cleanup.
|
||||
|
||||
`ota` is an opt-in physical A/B test. It erases the explicitly selected test
|
||||
board's flash, serial-flashes image A, then uses the real portal browser form
|
||||
to upload image B. It requires the automatic restart and two independent
|
||||
B-marker responses; a manual reset never makes the test pass. On success the
|
||||
selected board remains on B in the portal-only fixture.
|
||||
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=""
|
||||
capture_readme_media="no"
|
||||
custom_parameter_stress="no"
|
||||
ota_environment_name=""
|
||||
ota_firmware_a=""
|
||||
ota_firmware_b=""
|
||||
ota_browser_prebuilt="no"
|
||||
ota_serial_capture_pid=""
|
||||
ota_serial_capture_log=""
|
||||
ota_serial_capture_ready=""
|
||||
ota_serial_capture_status=""
|
||||
station_handoff_env_file=""
|
||||
station_handoff_fixture_restore_needed="no"
|
||||
# `wm_create_portal_connection` sets these before NetworkManager mutates the
|
||||
# secondary adapter. Keeping the ownership record in-process means the
|
||||
# signal trap can clean up the exact pending or active connection immediately.
|
||||
WM_PORTAL_CONNECTION_UUID=""
|
||||
WM_PORTAL_CONNECTION_NAME=""
|
||||
WM_PORTAL_CONNECTION_OWNED="no"
|
||||
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 ;;
|
||||
--capture-readme-media) capture_readme_media="yes"; shift ;;
|
||||
--custom-parameter-stress) custom_parameter_stress="yes"; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
require_common() {
|
||||
wm_require ip
|
||||
wm_require nmcli
|
||||
wm_pio_available
|
||||
wm_require docker
|
||||
wm_require curl
|
||||
docker compose version >/dev/null
|
||||
}
|
||||
|
||||
require_ota_serial_capture() {
|
||||
wm_require python3
|
||||
python3 -c 'import serial' >/dev/null 2>&1 || {
|
||||
echo "Portal OTA serial evidence requires Python pyserial (for example, python3-serial)." >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
pio_for_portal_environment() {
|
||||
local environment="$1"
|
||||
shift
|
||||
|
||||
case "$environment" in
|
||||
# Keep the maintained Core 3.3.11 graph in the same persistent cache
|
||||
# as the ESP32 A/B fixture. It is never cleared by this test harness.
|
||||
esp32|esp32_ota)
|
||||
;;
|
||||
*)
|
||||
wm_pio "$@"
|
||||
return
|
||||
;;
|
||||
esac
|
||||
|
||||
local core_dir packages_dir cache_dir
|
||||
core_dir="${WIFIMANAGER_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11}"
|
||||
packages_dir="${WIFIMANAGER_PLATFORMIO_PACKAGES_DIR:-$core_dir/packages}"
|
||||
cache_dir="${WIFIMANAGER_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
|
||||
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
|
||||
PLATFORMIO_CORE_DIR="$core_dir" PLATFORMIO_PACKAGES_DIR="$packages_dir" \
|
||||
PLATFORMIO_CACHE_DIR="$cache_dir" wm_pio "$@"
|
||||
}
|
||||
|
||||
prepare_output_dir() {
|
||||
if [[ -z "$output_dir" ]]; then
|
||||
if [[ "$capture_readme_media" == "yes" ]]; then
|
||||
output_dir="$root/artifacts/readme-media/$(date -u +%Y%m%dT%H%M%SZ)-$platform"
|
||||
else
|
||||
output_dir="$(wm_portal_state_root)/runs/$(date -u +%Y%m%dT%H%M%SZ)-$platform"
|
||||
fi
|
||||
fi
|
||||
install -d -m 700 "$output_dir"
|
||||
output_dir="$(cd "$output_dir" && pwd)"
|
||||
}
|
||||
|
||||
prepare_ota_identity_input() {
|
||||
# The generated A/B marker belongs to this run, never to shared fixture
|
||||
# source. Keeping it with the private artifacts prevents cross-run drift.
|
||||
export WIFIMANAGER_OTA_IDENTITY_DIR="$output_dir/ota-fixture-input"
|
||||
install -d -m 700 "$WIFIMANAGER_OTA_IDENTITY_DIR"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if [[ -n "$station_env" ]]; then
|
||||
[[ "$command_name" == "run" ]] || {
|
||||
echo "--station-env is supported only by the browser-backed run command." >&2
|
||||
exit 2
|
||||
}
|
||||
[[ "$browser" == "auto" ]] || {
|
||||
echo "--station-env requires --browser auto so the real hand-off is exercised." >&2
|
||||
exit 2
|
||||
}
|
||||
[[ "$capture_readme_media" == "no" ]] || {
|
||||
echo "--station-env cannot be combined with README media capture." >&2
|
||||
exit 2
|
||||
}
|
||||
fi
|
||||
if [[ "$capture_readme_media" == "yes" ]]; then
|
||||
[[ "$command_name" == "run" && "$platform" == "esp32" ]] || {
|
||||
echo "--capture-readme-media is supported only by run --platform esp32" >&2
|
||||
exit 2
|
||||
}
|
||||
[[ "$browser" == "auto" ]] || {
|
||||
echo "--capture-readme-media requires --browser auto" >&2
|
||||
exit 2
|
||||
}
|
||||
fi
|
||||
if [[ "$custom_parameter_stress" == "yes" ]]; then
|
||||
[[ "$command_name" == "run" && "$platform" == "esp8266" ]] || {
|
||||
echo "--custom-parameter-stress is supported only by run --platform esp8266" >&2
|
||||
exit 2
|
||||
}
|
||||
[[ "$browser" == "auto" ]] || {
|
||||
echo "--custom-parameter-stress requires --browser auto" >&2
|
||||
exit 2
|
||||
}
|
||||
fi
|
||||
if [[ "$command_name" == "ota" ]]; then
|
||||
[[ "$browser" == "auto" ]] || {
|
||||
echo "OTA coverage always uses the browser; --browser skip is not supported." >&2
|
||||
exit 2
|
||||
}
|
||||
[[ -z "$station_env" ]] || {
|
||||
echo "OTA coverage is portal-only and does not accept --station-env." >&2
|
||||
exit 2
|
||||
}
|
||||
[[ "$capture_readme_media" == "no" && "$custom_parameter_stress" == "no" ]] || {
|
||||
echo "OTA coverage cannot be combined with README media or parameter-stress modes." >&2
|
||||
exit 2
|
||||
}
|
||||
fi
|
||||
}
|
||||
|
||||
read_station_handoff_value() {
|
||||
local requested_key="$1" line value="" found=no
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
# Accept the documented KEY=VALUE file format without evaluating it as
|
||||
# shell code. A value may contain '=' but not a second declaration of
|
||||
# the same key, which would make the generated minimal file ambiguous.
|
||||
line="${line%$'\r'}"
|
||||
case "$line" in
|
||||
"$requested_key"=*)
|
||||
[[ "$found" == no ]] || {
|
||||
echo "Station environment defines $requested_key more than once." >&2
|
||||
return 1
|
||||
}
|
||||
value="${line#*=}"
|
||||
found=yes
|
||||
;;
|
||||
esac
|
||||
done <"$station_env"
|
||||
[[ "$found" == yes && -n "$value" ]] || {
|
||||
echo "Station environment must define a non-empty $requested_key value." >&2
|
||||
return 1
|
||||
}
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
prepare_station_handoff_env() {
|
||||
local ssid password
|
||||
ssid="$(read_station_handoff_value WIFI_SSID)" || return 1
|
||||
password="$(read_station_handoff_value WIFI_PASSWORD)" || return 1
|
||||
station_handoff_env_file="$(mktemp "$output_dir/.portal-station.XXXXXX")" || {
|
||||
echo 'Could not create the private station-handoff environment file.' >&2
|
||||
return 1
|
||||
}
|
||||
if ! {
|
||||
printf 'WIFI_SSID=%s\n' "$ssid"
|
||||
printf 'WIFI_PASSWORD=%s\n' "$password"
|
||||
} >"$station_handoff_env_file"; then
|
||||
rm -f -- "$station_handoff_env_file"
|
||||
station_handoff_env_file=""
|
||||
echo 'Could not write the private station-handoff environment file.' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! chmod 600 "$station_handoff_env_file"; then
|
||||
rm -f -- "$station_handoff_env_file"
|
||||
station_handoff_env_file=""
|
||||
echo 'Could not protect the private station-handoff environment file.' >&2
|
||||
return 1
|
||||
fi
|
||||
export PORTAL_STATION_ENV_HOST="$station_handoff_env_file"
|
||||
}
|
||||
|
||||
remove_station_handoff_env() {
|
||||
[[ -n "$station_handoff_env_file" ]] || return 0
|
||||
if ! rm -f -- "$station_handoff_env_file"; then
|
||||
echo 'Could not remove the private station-handoff environment file.' >&2
|
||||
return 1
|
||||
fi
|
||||
station_handoff_env_file=""
|
||||
unset PORTAL_STATION_ENV_HOST
|
||||
}
|
||||
|
||||
restore_station_handoff_fixture() {
|
||||
local ssid
|
||||
[[ "$station_handoff_fixture_restore_needed" == yes ]] || return 0
|
||||
# The optional browser hand-off deliberately persists its supplied station
|
||||
# credentials long enough to exercise WiFiManager's real connect path.
|
||||
# Reflash the same portal-only fixture afterwards: its setup() calls
|
||||
# resetSettings(), so the selected test board returns to a no-station
|
||||
# state without retaining a developer's Wi-Fi credentials. This is normal
|
||||
# test cleanup, not OTA evidence; the OTA command never accepts a station
|
||||
# environment and never calls this function.
|
||||
printf 'Restoring the clean portal-only fixture after station hand-off.\n'
|
||||
if ! pio_for_portal_environment "$platform" run -d "$root/test/portal-harness" -e "$platform" \
|
||||
-t upload --upload-port "$port"; then
|
||||
echo 'Could not restore the clean portal-only fixture after station hand-off.' >&2
|
||||
return 1
|
||||
fi
|
||||
# The board deliberately disappears during the cleanup reflash. Require
|
||||
# its fixture AP to return and be routed through the owned secondary
|
||||
# adapter before declaring the developer-supplied credentials scrubbed.
|
||||
ssid="$(wm_portal_ssid "$platform")" || return 1
|
||||
if ! wm_wait_for_portal_ssid "$client_interface" "$ssid" || \
|
||||
! wm_verify_portal_route "$client_interface" || \
|
||||
! wait_for_portal_scan_ready; then
|
||||
echo 'The clean portal-only fixture did not return after station hand-off cleanup.' >&2
|
||||
return 1
|
||||
fi
|
||||
station_handoff_fixture_restore_needed="no"
|
||||
}
|
||||
|
||||
write_readme_media_manifest() {
|
||||
local media_dir="$output_dir/readme-media"
|
||||
local required
|
||||
for required in portal-tour.gif portal-overview.png portal-wifi-settings.png; do
|
||||
[[ -s "$media_dir/$required" ]] || {
|
||||
echo "README media output is incomplete: $media_dir/$required" >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
printf '{"schema":1,"project":"WiFiManager","kind":"readme-media","status":"passed","platform":"esp32","created_at":"%s"}\n' \
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$media_dir/manifest.json"
|
||||
chmod 600 "$media_dir/manifest.json"
|
||||
}
|
||||
|
||||
portal_scan_status() {
|
||||
curl --interface "$client_interface" --connect-timeout 3 --max-time 5 \
|
||||
--silent --show-error --fail http://192.168.4.1/api/wifi/scan-status 2>/dev/null
|
||||
}
|
||||
|
||||
wait_for_portal_http_ready() {
|
||||
# The AP can be visible before the portal server has completed startup.
|
||||
# OTA requires the update UI and its HTTP route; it does not require an
|
||||
# unrelated background scan to have succeeded.
|
||||
local attempt response
|
||||
for attempt in $(seq 1 45); do
|
||||
response="$(portal_scan_status || true)"
|
||||
if [[ "$response" == *'"state"'* ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo 'Portal HTTP API did not become ready within 45 seconds.' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_portal_scan_ready() {
|
||||
# The normal portal UI suite exercises the automatic scan and still
|
||||
# requires a usable result. Keep that coverage separate from HTTP OTA.
|
||||
local attempt response
|
||||
for attempt in $(seq 1 45); do
|
||||
response="$(portal_scan_status || true)"
|
||||
if [[ "$response" == *'"state":"complete"'* && "$response" == *'"results_valid":true'* ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "$response" == *'"state":"failed"'* || "$response" == *'"state":"timeout"'* ]]; then
|
||||
echo "Portal background scan did not become ready: $response" >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo 'Portal did not complete its initial Wi-Fi scan within 45 seconds.' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
report_initial_portal_scan_status() {
|
||||
local response
|
||||
response="$(portal_scan_status || true)"
|
||||
if [[ -n "$response" ]]; then
|
||||
printf 'Initial portal scan status: %s\n' "$response"
|
||||
else
|
||||
echo 'Initial portal scan status was unavailable after portal startup.' >&2
|
||||
fi
|
||||
}
|
||||
|
||||
start_portal_session() {
|
||||
local environment="${1:-$platform}" erase_before_upload="${2:-no}" expected_a_artifact="${3:-}" capture_ota_serial="${4:-no}" require_scan="${5:-yes}" ssid reconnect_after_drop="no"
|
||||
validate_run_arguments
|
||||
require_common
|
||||
wm_acquire_hardware_lock
|
||||
wm_require_no_active_session
|
||||
# Choose the real NetworkManager authorization path before inspecting the
|
||||
# secondary adapter. A headless shell must not mistake a denied inspection
|
||||
# for an idle adapter and then replace a connection it does not own.
|
||||
wm_prepare_networkmanager_authorization
|
||||
wm_require_client_adapter "$client_interface" "$takeover"
|
||||
wm_harness_lock_serial_port "$port"
|
||||
prepare_output_dir
|
||||
ssid="$(wm_portal_ssid "$platform")"
|
||||
|
||||
if [[ -n "$expected_a_artifact" ]]; then
|
||||
# B was built most recently. Restore the generated A identity before
|
||||
# PlatformIO verifies and serial-flashes the immutable A artifact.
|
||||
wm_write_ota_fixture_identity A
|
||||
pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment"
|
||||
ota_assert_a_artifact_matches_build "$expected_a_artifact"
|
||||
fi
|
||||
if [[ "$erase_before_upload" == "yes" ]]; then
|
||||
# OTA selection metadata must not survive from a previous fixture run:
|
||||
# otherwise a bootloader could select a stale app slot instead of A.
|
||||
pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment" -t erase --upload-port "$port"
|
||||
fi
|
||||
if [[ -n "$expected_a_artifact" ]]; then
|
||||
ota_assert_a_artifact_matches_build "$expected_a_artifact"
|
||||
fi
|
||||
pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment" -t upload --upload-port "$port"
|
||||
if [[ "$capture_ota_serial" == yes ]]; then
|
||||
# Attach only after PlatformIO releases the serial port. Capture the
|
||||
# complete A boot, including portal start and DHCP, before the adapter
|
||||
# is asked to associate with the fixture AP.
|
||||
start_ota_serial_capture
|
||||
fi
|
||||
if [[ -n "$expected_a_artifact" ]]; then
|
||||
ota_assert_a_artifact_matches_build "$expected_a_artifact"
|
||||
fi
|
||||
# OTA and the optional station hand-off both deliberately reboot the test
|
||||
# board. Let only those owned disposable profiles reassociate after that
|
||||
# outage; ordinary portal work keeps the secondary adapter inert after its
|
||||
# current run.
|
||||
if [[ "$capture_ota_serial" == yes || -n "$station_env" ]]; then
|
||||
reconnect_after_drop=yes
|
||||
fi
|
||||
if ! wm_create_portal_connection "$client_interface" "$ssid" "default1" "$platform" "$reconnect_after_drop"; then
|
||||
return 1
|
||||
fi
|
||||
if ! wm_verify_portal_route "$client_interface"; then
|
||||
wm_cleanup_created_connection
|
||||
return 1
|
||||
fi
|
||||
if [[ "$require_scan" == yes ]]; then
|
||||
wait_for_portal_scan_ready || {
|
||||
wm_cleanup_created_connection
|
||||
return 1
|
||||
}
|
||||
elif ! wait_for_portal_http_ready; then
|
||||
wm_cleanup_created_connection
|
||||
return 1
|
||||
fi
|
||||
[[ "$require_scan" == yes ]] || report_initial_portal_scan_status
|
||||
printf 'Portal connected on %s. Artifacts: %s\n' "$client_interface" "$output_dir"
|
||||
}
|
||||
|
||||
ota_environment() {
|
||||
printf '%s_ota\n' "$platform"
|
||||
}
|
||||
|
||||
ota_marker_field() {
|
||||
local response="$1" field="$2"
|
||||
case "$field" in
|
||||
marker|freeSketchSpace)
|
||||
# The marker endpoint is deliberately fixture-only JSON. Parse it
|
||||
# as JSON instead of maintaining a second, fragile copy of its
|
||||
# wire format in a regular expression.
|
||||
python3 -c '
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
value = json.load(sys.stdin)[sys.argv[1]]
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
raise SystemExit(1)
|
||||
|
||||
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
||||
raise SystemExit(1)
|
||||
print(value)
|
||||
' "$field" <<<"$response" 2>/dev/null || true
|
||||
;;
|
||||
*)
|
||||
echo "Unknown OTA marker field: $field" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
ota_fetch_marker() {
|
||||
curl --interface "$client_interface" --connect-timeout 1 --max-time 2 \
|
||||
--silent --show-error http://192.168.4.1/api/test/firmware-marker 2>/dev/null
|
||||
}
|
||||
|
||||
wait_for_ota_marker() {
|
||||
local expected="$1" deadline response marker
|
||||
deadline=$((SECONDS + 75))
|
||||
while (( SECONDS < deadline )); do
|
||||
response="$(ota_fetch_marker || true)"
|
||||
marker="$(ota_marker_field "$response" marker)"
|
||||
if [[ "$marker" == "$expected" ]]; then
|
||||
printf '%s\n' "$response"
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
echo "Portal OTA fixture did not report marker '$expected' within 75 seconds." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
ota_esp32_partition_row() {
|
||||
local partition="$1" table="$root/test/portal-harness/partitions/esp32_ota_4m_no_fs.csv"
|
||||
[[ -r "$table" ]] || {
|
||||
echo "ESP32 OTA partition table is unreadable: $table" >&2
|
||||
return 1
|
||||
}
|
||||
awk -F, -v partition="$partition" '
|
||||
function trim(value) {
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
||||
return value
|
||||
}
|
||||
/^[[:space:]]*#/ || NF < 5 { next }
|
||||
trim($1) == partition {
|
||||
printf "%s\t%s\t%s\t%s\n", trim($2), trim($3), trim($4), trim($5)
|
||||
exit
|
||||
}
|
||||
' "$table"
|
||||
}
|
||||
|
||||
ota_esp32_require_partition() {
|
||||
local name="$1" expected_type="$2" expected_subtype="$3" expected_offset="$4" expected_size="$5"
|
||||
local row type subtype offset size
|
||||
row="$(ota_esp32_partition_row "$name")"
|
||||
IFS=$'\t' read -r type subtype offset size <<<"$row"
|
||||
[[ "$type" == "$expected_type" && "$subtype" == "$expected_subtype" && \
|
||||
"$offset" == "$expected_offset" && "$size" == "$expected_size" ]] || {
|
||||
echo "ESP32 OTA partition '$name' must be $expected_type/$expected_subtype at $expected_offset with size $expected_size; found '${row:-missing}'." >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
ota_esp32_slot_size() {
|
||||
local table="$root/test/portal-harness/partitions/esp32_ota_4m_no_fs.csv"
|
||||
local value_a value_b
|
||||
[[ -r "$table" ]] || {
|
||||
echo "ESP32 OTA partition table is unreadable: $table" >&2
|
||||
return 1
|
||||
}
|
||||
ota_esp32_require_partition nvs data nvs 0x9000 0x5000
|
||||
ota_esp32_require_partition otadata data ota 0xe000 0x2000
|
||||
ota_esp32_require_partition app0 app ota_0 0x10000 0x1F0000
|
||||
ota_esp32_require_partition app1 app ota_1 0x200000 0x1F0000
|
||||
value_a="$(ota_esp32_partition_row app0 | awk -F '\t' '{ print $4 }')"
|
||||
value_b="$(ota_esp32_partition_row app1 | awk -F '\t' '{ print $4 }')"
|
||||
[[ "$value_a" =~ ^0x[0-9A-Fa-f]+$ && "$value_a" == "$value_b" ]] || {
|
||||
echo "ESP32 OTA app slots must have equal hexadecimal capacities: app0=$value_a app1=$value_b" >&2
|
||||
return 1
|
||||
}
|
||||
printf '%d\n' "$((value_b))"
|
||||
}
|
||||
|
||||
ota_assert_firmware_fits() {
|
||||
local firmware="$1" capacity="$2" label="$3" image_size
|
||||
[[ -n "$firmware" && -s "$firmware" ]] || {
|
||||
echo "OTA $label firmware artifact is missing." >&2
|
||||
return 1
|
||||
}
|
||||
[[ "$capacity" =~ ^[0-9]+$ && "$capacity" -gt 0 ]] || {
|
||||
echo "OTA capacity is invalid: $capacity" >&2
|
||||
return 1
|
||||
}
|
||||
image_size="$(wc -c < "$firmware")"
|
||||
if (( image_size > capacity )); then
|
||||
echo "OTA $label image is too large: ${image_size} bytes exceeds ${capacity} bytes." >&2
|
||||
return 1
|
||||
fi
|
||||
printf 'OTA %s image fits the available update space: %s <= %s bytes\n' "$label" "$image_size" "$capacity"
|
||||
}
|
||||
|
||||
build_ota_image() {
|
||||
local image="$1" source artifact
|
||||
case "$image" in
|
||||
A)
|
||||
artifact="$output_dir/${platform}-portal-ota-a.bin"
|
||||
ota_firmware_a="$artifact"
|
||||
;;
|
||||
B)
|
||||
artifact="$output_dir/${platform}-portal-ota-b.bin"
|
||||
ota_firmware_b="$artifact"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown portal OTA fixture image: $image" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
wm_write_ota_fixture_identity "$image"
|
||||
pio_for_portal_environment "$ota_environment_name" run -d "$root/test/portal-harness" -e "$ota_environment_name"
|
||||
source="$root/test/portal-harness/.pio/build/$ota_environment_name/firmware.bin"
|
||||
[[ -s "$source" ]] || {
|
||||
echo "PlatformIO did not produce portal OTA image $image." >&2
|
||||
return 1
|
||||
}
|
||||
install -m 600 "$source" "$artifact"
|
||||
}
|
||||
|
||||
prepare_ota_firmware() {
|
||||
local capacity
|
||||
ota_environment_name="$(ota_environment)"
|
||||
wm_lock_ota_fixture_environment "$ota_environment_name"
|
||||
|
||||
# Capture immutable A before rebuilding the same environment as B. The
|
||||
# generated header is the only changed input, so PlatformIO reuses library
|
||||
# objects while the browser still mounts a distinct B artifact read-only.
|
||||
build_ota_image A
|
||||
build_ota_image B
|
||||
if cmp -s "$ota_firmware_a" "$ota_firmware_b"; then
|
||||
echo "PlatformIO produced identical OTA A and B images." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$platform" == "esp32" ]]; then
|
||||
"$root/tools/check-ota-partitions.sh"
|
||||
capacity="$(ota_esp32_slot_size)"
|
||||
ota_assert_firmware_fits "$ota_firmware_a" "$capacity" A
|
||||
ota_assert_firmware_fits "$ota_firmware_b" "$capacity" B
|
||||
fi
|
||||
}
|
||||
|
||||
ota_assert_a_artifact_matches_build() {
|
||||
local expected_artifact="$1" source
|
||||
source="$root/test/portal-harness/.pio/build/$ota_environment_name/firmware.bin"
|
||||
[[ -s "$expected_artifact" && -s "$source" ]] || {
|
||||
echo "Prepared OTA A artifact or PlatformIO build output is missing." >&2
|
||||
return 1
|
||||
}
|
||||
cmp -s "$expected_artifact" "$source" || {
|
||||
echo "PlatformIO's A build no longer matches the immutable artifact prepared before serial flashing." >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
validate_running_ota_capacity() {
|
||||
local marker_response free_sketch_space capacity
|
||||
[[ "$platform" == "esp8266" ]] || return 0
|
||||
marker_response="$(wait_for_ota_marker A)"
|
||||
free_sketch_space="$(ota_marker_field "$marker_response" freeSketchSpace)"
|
||||
[[ "$free_sketch_space" =~ ^[0-9]+$ && "$free_sketch_space" -gt 4096 ]] || {
|
||||
echo "ESP8266 fixture did not report usable free sketch space." >&2
|
||||
return 1
|
||||
}
|
||||
# Match WiFiManagerHandlers::handleUpdating(): Update.begin receives this
|
||||
# aligned value, not the raw ESP.getFreeSketchSpace() number.
|
||||
capacity=$(( (free_sketch_space - 0x1000) & 0xFFFFF000 ))
|
||||
ota_assert_firmware_fits "$ota_firmware_b" "$capacity" B
|
||||
}
|
||||
|
||||
configure_ota_harness_environment() {
|
||||
export PORTAL_ARTIFACT_DIR="$output_dir"
|
||||
export LOCAL_UID="$(id -u)"
|
||||
export LOCAL_GID="$(id -g)"
|
||||
export PORTAL_BROWSER_MODE=auto
|
||||
# OTA is its own test harness. Do not inherit a developer's unrelated media,
|
||||
# stress, target, or test-selection setting into a physical firmware run.
|
||||
export PORTAL_HARNESS_DOCKER_TARGET=portal-harness
|
||||
export PORTAL_CAPTURE_README_MEDIA=0
|
||||
export PORTAL_CUSTOM_PARAMETER_STRESS=0
|
||||
export PORTAL_TEST_FILE=tests/ota.spec.js
|
||||
export PORTAL_PLATFORM="$platform"
|
||||
export PORTAL_OTA_FIRMWARE_HOST="$ota_firmware_b"
|
||||
export PORTAL_OTA_INITIAL_MARKER=A
|
||||
export PORTAL_OTA_EXPECTED_MARKER=B
|
||||
}
|
||||
|
||||
ota_compose_files() {
|
||||
printf '%s\n' \
|
||||
-f "$root/tests/portal-harness/compose.yaml" \
|
||||
-f "$root/tests/portal-harness/compose.ota.yaml"
|
||||
}
|
||||
|
||||
prepare_ota_harness_image() {
|
||||
local -a compose_files
|
||||
configure_ota_harness_environment
|
||||
mapfile -t compose_files < <(ota_compose_files)
|
||||
# Build before A is flashed. A cold Playwright build can otherwise consume
|
||||
# the finite portal window before the browser has connected.
|
||||
docker compose "${compose_files[@]}" build portal-harness
|
||||
ota_browser_prebuilt=yes
|
||||
}
|
||||
|
||||
run_ota_harness() {
|
||||
local -a compose_files
|
||||
configure_ota_harness_environment
|
||||
mapfile -t compose_files < <(ota_compose_files)
|
||||
[[ "$ota_browser_prebuilt" == yes ]] || {
|
||||
echo "Portal OTA browser image was not prepared before A was flashed." >&2
|
||||
return 1
|
||||
}
|
||||
docker compose "${compose_files[@]}" run --rm portal-harness
|
||||
}
|
||||
|
||||
start_ota_serial_capture() {
|
||||
local attempt
|
||||
ota_serial_capture_log="$output_dir/serial-ota.log"
|
||||
ota_serial_capture_ready="$output_dir/.serial-ota.ready"
|
||||
ota_serial_capture_status="$output_dir/serial-ota-capture.log"
|
||||
rm -f -- "$ota_serial_capture_ready"
|
||||
install -m 600 /dev/null "$ota_serial_capture_log"
|
||||
install -m 600 /dev/null "$ota_serial_capture_status"
|
||||
python3 "$root/tools/capture-serial.py" \
|
||||
--port "$port" \
|
||||
--output "$ota_serial_capture_log" \
|
||||
--ready-file "$ota_serial_capture_ready" \
|
||||
--deadline-seconds 600 \
|
||||
>>"$ota_serial_capture_status" 2>&1 &
|
||||
ota_serial_capture_pid=$!
|
||||
|
||||
for attempt in $(seq 1 50); do
|
||||
[[ -f "$ota_serial_capture_ready" ]] && return 0
|
||||
if ! kill -0 "$ota_serial_capture_pid" >/dev/null 2>&1; then
|
||||
wait "$ota_serial_capture_pid" || true
|
||||
ota_serial_capture_pid=""
|
||||
echo "Portal OTA serial recorder exited before it became ready: $ota_serial_capture_status" >&2
|
||||
return 1
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
echo "Portal OTA serial recorder did not become ready: $ota_serial_capture_status" >&2
|
||||
stop_ota_serial_capture || true
|
||||
return 1
|
||||
}
|
||||
|
||||
require_ota_serial_capture_running() {
|
||||
[[ -n "$ota_serial_capture_pid" ]] && kill -0 "$ota_serial_capture_pid" >/dev/null 2>&1 || {
|
||||
echo "Portal OTA serial recorder stopped unexpectedly: $ota_serial_capture_status" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
stop_ota_serial_capture() {
|
||||
local status=0
|
||||
[[ -n "$ota_serial_capture_pid" ]] || return 0
|
||||
if kill -0 "$ota_serial_capture_pid" >/dev/null 2>&1; then
|
||||
kill -TERM "$ota_serial_capture_pid" >/dev/null 2>&1 || status=1
|
||||
fi
|
||||
if ! wait "$ota_serial_capture_pid"; then
|
||||
status=1
|
||||
fi
|
||||
ota_serial_capture_pid=""
|
||||
(( status == 0 )) || return "$status"
|
||||
}
|
||||
|
||||
finish_portal_session() {
|
||||
# A normal run already owns an in-process record. `down` starts fresh, so
|
||||
# load its retained exact record and select the scoped authorization path
|
||||
# before attempting deletion.
|
||||
if [[ "${WM_PORTAL_CONNECTION_OWNED:-no}" != "yes" ]]; then
|
||||
wm_load_state
|
||||
wm_prepare_networkmanager_authorization
|
||||
WM_PORTAL_CONNECTION_OWNED=yes
|
||||
fi
|
||||
wm_cleanup_created_connection
|
||||
}
|
||||
|
||||
cleanup_portal_session() {
|
||||
local status="${1:-$?}"
|
||||
# A signal must never fall through into the rest of the hardware run, and
|
||||
# a recursive EXIT trap must not obscure the original status.
|
||||
trap - EXIT HUP INT TERM
|
||||
stop_ota_serial_capture || true
|
||||
wm_remove_ota_fixture_identity
|
||||
if ! restore_station_handoff_fixture; then
|
||||
(( status != 0 )) || status=1
|
||||
fi
|
||||
if ! remove_station_handoff_env; then
|
||||
(( status != 0 )) || status=1
|
||||
fi
|
||||
if [[ "${WM_PORTAL_CONNECTION_OWNED:-no}" == "yes" ]] && \
|
||||
{ (( status != 0 )) || { [[ "$command_name" != "up" && "$keep" != "yes" ]]; }; }; then
|
||||
if ! wm_cleanup_created_connection; then
|
||||
# Preserve an existing run failure, but never turn an otherwise
|
||||
# successful browser/OTA run into a claimed pass when its owned
|
||||
# NetworkManager connection could not be removed.
|
||||
(( status != 0 )) || status=1
|
||||
fi
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
trap 'cleanup_portal_session "$?"' EXIT
|
||||
trap 'cleanup_portal_session 129' HUP
|
||||
trap 'cleanup_portal_session 130' INT
|
||||
trap 'cleanup_portal_session 143' TERM
|
||||
|
||||
case "$command_name" in
|
||||
doctor)
|
||||
[[ -n "$client_interface" ]] || usage
|
||||
require_common
|
||||
wm_acquire_hardware_lock
|
||||
# Doctor is read-only, but it must validate the same authorization
|
||||
# path as a real portal command before it calls an adapter safe.
|
||||
wm_prepare_networkmanager_authorization
|
||||
wm_require_client_adapter "$client_interface" "$takeover"
|
||||
wm_report_networkmanager_authorization
|
||||
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
|
||||
if ! finish_portal_session; then
|
||||
trap - EXIT HUP INT TERM
|
||||
exit 1
|
||||
fi
|
||||
echo 'Portal client connection removed.'
|
||||
;;
|
||||
run)
|
||||
start_portal_session
|
||||
export PORTAL_ARTIFACT_DIR="$output_dir"
|
||||
export LOCAL_UID="$(id -u)"
|
||||
export LOCAL_GID="$(id -g)"
|
||||
export PORTAL_BROWSER_MODE="$browser"
|
||||
# A normal run is the full portal suite; do not let a prior OTA shell
|
||||
# environment restrict it to one spec.
|
||||
export PORTAL_TEST_FILE=''
|
||||
export PORTAL_CUSTOM_PARAMETER_STRESS=0
|
||||
if [[ "$custom_parameter_stress" == "yes" ]]; then
|
||||
export PORTAL_CUSTOM_PARAMETER_STRESS=1
|
||||
fi
|
||||
export PORTAL_PLATFORM="$platform"
|
||||
if [[ "$capture_readme_media" == "yes" ]]; then
|
||||
export PORTAL_CAPTURE_README_MEDIA=1
|
||||
export PORTAL_HARNESS_DOCKER_TARGET=media
|
||||
else
|
||||
export PORTAL_CAPTURE_README_MEDIA=0
|
||||
export PORTAL_HARNESS_DOCKER_TARGET=portal-harness
|
||||
fi
|
||||
compose_files=(-f "$root/tests/portal-harness/compose.yaml")
|
||||
if [[ -n "$station_env" ]]; then
|
||||
prepare_station_handoff_env
|
||||
compose_files+=(-f "$root/tests/portal-harness/compose.station.yaml")
|
||||
# Once the browser can receive the private station credentials,
|
||||
# every exit path must return the board to the fixture's clean
|
||||
# portal-only state. The EXIT trap covers a browser failure; the
|
||||
# explicit success-path call below keeps a cleanup failure from
|
||||
# being announced as a passing test.
|
||||
station_handoff_fixture_restore_needed=yes
|
||||
fi
|
||||
# The test-harness 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-harness
|
||||
docker compose "${compose_files[@]}" run --rm portal-harness
|
||||
restore_station_handoff_fixture
|
||||
remove_station_handoff_env
|
||||
if [[ "$capture_readme_media" == "yes" ]]; then
|
||||
write_readme_media_manifest
|
||||
fi
|
||||
if [[ "$keep" != "yes" ]] && ! finish_portal_session; then
|
||||
trap - EXIT HUP INT TERM
|
||||
exit 1
|
||||
fi
|
||||
printf 'Portal test harness passed. Artifacts: %s\n' "$output_dir"
|
||||
if [[ "$keep" == "yes" ]]; then
|
||||
printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n'
|
||||
fi
|
||||
;;
|
||||
ota)
|
||||
# Acquire the same board/adapter lock before *any* A/B preparation.
|
||||
# Otherwise two OTA invocations can collide in .pio or create a
|
||||
# same-second artifact directory before either reaches serial flash.
|
||||
validate_run_arguments
|
||||
require_common
|
||||
wm_acquire_hardware_lock
|
||||
wm_require_no_active_session
|
||||
wm_prepare_networkmanager_authorization
|
||||
wm_require_client_adapter "$client_interface" "$takeover"
|
||||
require_ota_serial_capture
|
||||
prepare_output_dir
|
||||
prepare_ota_identity_input
|
||||
prepare_ota_firmware
|
||||
prepare_ota_harness_image
|
||||
start_portal_session "$ota_environment_name" yes "$ota_firmware_a" yes no
|
||||
|
||||
# A must be visible through the real portal before a B upload can be
|
||||
# meaningful. On ESP8266 the marker also reports the actual active
|
||||
# sketch-space limit, which is checked before the browser sees B.
|
||||
wait_for_ota_marker A >/dev/null
|
||||
validate_running_ota_capacity
|
||||
run_ota_harness
|
||||
|
||||
# The browser test harness has already required an outage and two B
|
||||
# observations. Repeat the host-side observation after the container
|
||||
# exits so the named secondary adapter is also proven to rediscover
|
||||
# the returned AP and see B. This is AP routing, not a LAN IP shortcut.
|
||||
wm_wait_for_portal_ssid "$client_interface" "$(wm_portal_ssid "$platform")"
|
||||
wm_verify_portal_route "$client_interface"
|
||||
wait_for_ota_marker B >/dev/null
|
||||
require_ota_serial_capture_running
|
||||
sleep 1
|
||||
wait_for_ota_marker B >/dev/null
|
||||
require_ota_serial_capture_running
|
||||
stop_ota_serial_capture
|
||||
if [[ "$keep" != "yes" ]] && ! finish_portal_session; then
|
||||
trap - EXIT HUP INT TERM
|
||||
exit 1
|
||||
fi
|
||||
printf 'Portal HTTP OTA test harness 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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user