diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00f909f..ac55477 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,12 +2,24 @@ name: Build on: push: + branches: + - device-framework pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read jobs: + documentation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: ./scripts/check-docs.sh + compile-tests: runs-on: ubuntu-latest strategy: @@ -20,4 +32,4 @@ jobs: with: python-version: '3.11' - run: python -m pip install --upgrade platformio==6.1.19 - - run: ./scripts/compile-check.sh --platform ${{ matrix.environment }} + - run: ./scripts/test.sh compile --platform ${{ matrix.environment }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9126114..3e4d75b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,14 @@ jobs: with: python-version: '3.11' - run: python -m pip install --upgrade platformio==6.1.19 + - run: ./scripts/test.sh compile --platform esp8266 + - run: ./scripts/test.sh compile --platform esp32 + - run: ./scripts/check-docs.sh - run: ./scripts/prepare-release.sh "$GITHUB_REF_NAME" - - run: gh release create "$GITHUB_REF_NAME" --generate-notes --title "$GITHUB_REF_NAME" + - run: ./scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md" + - run: >- + gh release create "$GITHUB_REF_NAME" + --title "WiFiManager $GITHUB_REF_NAME" + --notes-file "$RUNNER_TEMP/release-notes.md" env: GH_TOKEN: ${{ github.token }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4479e61..5ef6d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 3.1.0 + +- Add an opt-in primary/fallback station-profile controller with bounded failover, reconnection, a durable consumer-supplied store, and profile-aware portal APIs. DeviceFramework uses this to persist verified WiFi profiles transactionally. + ## 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. diff --git a/README.md b/README.md index 685a007..de706bb 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,15 @@ # WiFiManager -This repository is a **breaking fork** of upstream [`tzapu/WiFiManager`](https://github.com/tzapu/WiFiManager). -It is not a drop-in replacement for upstream behavior, APIs, templates, or portal customization patterns. +WiFiManager is the maintained ESP8266/ESP32 configuration-portal fork used by DeviceFramework. It is a deliberate breaking fork of upstream [`tzapu/WiFiManager`](https://github.com/tzapu/WiFiManager), with a streamed single-page portal and typed JSON APIs rather than upstream’s legacy page/template model. -If you are evaluating this fork, assume that core web-portal architecture has changed and review the code before adopting it in an existing upstream-based project. +## Why use it -**This repository:** [alexhopeoconnor/WiFiManager](https://github.com/alexhopeoconnor/WiFiManager) -**Upstream:** [tzapu/WiFiManager](https://github.com/tzapu/WiFiManager) +- **Single-shell portal:** one responsive SPA for WiFi, parameters, information, actions, and firmware update flow. +- **Data-first APIs:** portal state and actions are exposed under `/api/...`, not scraped from HTML. +- **Controlled customisation:** supported `portal*` APIs customise branding, parameters, cards, layout, and small style/JS enhancements without exposing portal internals. +- **ESP8266 and ESP32:** clean PlatformIO consumers resolve the right asynchronous TCP transport automatically. -## Basic provisioning - -For a standalone Arduino project, create one long-lived `WiFiManager` and call -`autoConnect` during setup. It first tries saved station credentials; when that -fails it starts the configuration portal with the supplied AP name and password. +## Try it ```cpp #include @@ -22,247 +19,45 @@ WiFiManager wifi; void setup() { Serial.begin(115200); - wifi.setConfigPortalTimeout(180); // seconds; 0 leaves it open - + wifi.setConfigPortalTimeout(180); if (!wifi.autoConnect("Device Setup", "change-me")) { ESP.restart(); } } -void loop() { - // Normal application work after WiFi is connected. -} +void loop() {} ``` -The AP password must meet the Wi-Fi password requirements. For applications -built on DeviceFramework, configure `setConfigDevicePassword(...)` instead: -DeviceFramework passes the same validated password to WiFiManager, Arduino OTA, -HTTP Basic authentication, and WebSerial. +For a DeviceFramework device, configure the framework’s shared device password instead. DeviceFramework applies it consistently to the provisioning AP, OTA, HTTP Basic authentication, and WebSerial. +## Explore the portal -## Breaking Changes +| Goal | Guide | +| --- | --- | +| Brand the portal, add parameters, cards, or small UI enhancements | [Portal customisation](docs/PORTAL_CUSTOMIZATION.md) | +| Configure primary/fallback station profiles | [Station profiles](docs/STATION_PROFILES.md) | +| Understand the JSON APIs and station-connect handoff | [Portal API](docs/PORTAL_API.md) | +| Build a clean consumer or work on this fork | [Testing](docs/TESTING.md) · [Development](docs/DEVELOPMENT.md) | -This fork intentionally modernizes and restructures the configuration portal. -Notable differences from upstream include: - -- The portal now serves a **single HTML shell** from `GET /`. -- Client navigation is handled as a **SPA with hash routing**. -- Interactive behavior is exposed through **JSON APIs under `/api/...`** plus firmware upload at **`POST /u`**. -- Legacy multi-page portal routes and legacy root-template override paths have been removed. -- The portal rendering pipeline is built around **DFTE** instead of upstream's monolithic HTML string assembly. -- JSON endpoints are expected to be **data-first**, not derived from generated HTML fragments. - -## Current Improvements And Modernization - -This fork currently includes the following architectural improvements: - -- A **single-shell portal architecture** with embedded bootstrap JSON and embedded application JS. -- A **SPA-based configuration UI** for WiFi setup, parameters, info views, device actions, and OTA flow. -- A **clean API surface** for WiFi scanning, WiFi save, parameters, info, status, restart, erase, portal exit, and captive-portal close behavior. -- **Captive portal redirect handling** retained while removing duplicate legacy UI architecture. -- **Data-first JSON generation** for portal APIs, including info/device/about data, instead of HTML-to-JSON parsing. -- **Capability-driven UI flags** in bootstrap/API payloads so features like info, update, erase, and action visibility can be controlled by backend state. -- **Portal bootstrap contract v2**: nested `brand`, `context`, `pages`, `actions`, `layout`, `extraHomeCards`; Wi-Fi meta params include `kind` (`field` | `html`) for first-class custom HTML parameters. -- **SPA-native feedback UX** using in-DOM dialog/toast behavior rather than page-based action flows. -- **Connect-on-save handoff**: when portal save triggers a station connect, `/api/wifi/connect-status` now reports a brief success state with `stationIp` and `redirectUrl` so the SPA can show the new address and redirect before the AP shuts down. -- **Request-scoped shell rendering**: the root portal page is built for each `GET /` from `WM_ROOT_SHELL_TEMPLATE` using a fresh placeholder registry. Shell inputs are `%PAGE_TITLE%`, `%STYLES%`, `%BOOTSTRAP_JSON%`, `%PORTAL_APP_JS%`, and `%PORTAL_APPEND_JS%` — filled in `WiFiManagerHandlers` from WiFiManager state and embedded assets (not from a server-wide template registry). -- **Customization via WiFiManager `portal*` APIs** (`portalSetBrandTitle`, `portalSetPageInfoVisible`, `portalSetLayoutParamsLocation`, `portalAddParameter`, asset hooks, etc.) and JSON under `/api/...`, not by exposing placeholder-registry mutation to consumers. -- A clearer separation between: - - shell rendering (handlers + SPA bootstrap) - - JSON API responses - - captive portal behavior - - OTA handling -- Updated tests focused on the **shell contract**, **bootstrap payloads**, and **API JSON shapes** rather than removed legacy portal pages. - -## Portal Customization Boundary - -Stable, supported portal customization is intentionally scoped: - -- `portalSetBrand*` for title, intro text, and logo SVG, plus `portalSetContextIdentityText(...)` for the user-facing runtime identity string on the home view. -- `portalSetPage*`, `portalSetAction*`, `portalSetLayout*`, and `portalSetBehavior*` for built-in portal capabilities and runtime behavior. -- `portalAddParameter(...)` for first-class custom parameters, including raw HTML blocks inside parameter-rendering surfaces (`#/wifi` or `#/setup`). -- `portalAddInfoSection(...)` and `portalAddHomeCard(...)` for structured extra content rendered by the built-in SPA. -- `portalAppendCss(...)`, `portalOverrideCss(...)`, and `portalAppendJs(...)` for light theming and enhancement hooks. - -Not part of the stable API: - -- arbitrary HTML injection into home/info/nav/shell -- replacing built-in SPA routing or action flow -- depending on undocumented DOM IDs or route internals -- treating `include/templates/*` as a supported consumer override surface - -Appended JS should enhance rather than replace the built-in SPA. The documented hook contract is: - -- `wm:ready` with `detail.boot` -- `wm:view-changed` with `detail.route` - -If a consumer needs custom live widgets, new primary navigation concepts, or new backend-to-frontend workflows, that is considered **fork territory** rather than portal customization. - -## Portal Customization Example - -```cpp -WiFiManager wm; - -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( - "" -); - -wm.portalSetPageInfoVisible(true); -wm.portalSetPageUpdateVisible(false); -wm.portalSetActionEraseVisible(false); -wm.portalSetActionRestartVisible(true); -wm.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage); - -wm.portalSetBehaviorCaptivePortalEnabled(true); -wm.portalSetBehaviorConnectOnSave(true); -wm.portalSetBehaviorExitAllowed(true); - -wm.portalSetFieldPasswordPlaceholderMode(PortalPasswordPlaceholderMode::Masked); -wm.portalSetFieldStaticIpVisibility(PortalFieldVisibility::Auto); -wm.portalSetFieldStaticDnsVisibility(PortalFieldVisibility::Auto); - -WiFiManagerParameter mqttHost( - "mqtt_host", - "MQTT host", - "broker.local", - 64, - "placeholder='broker.local'" -); -wm.portalAddParameter(&mqttHost); - -// Raw HTML remains first-class for custom parameters, but only inside the -// parameter-rendering surfaces (#/wifi or #/setup), not arbitrary portal regions. -WiFiManagerParameter gpsHelp( - "
" - "

GPS options below apply only when a GPS module is connected.

" - "
" -); -wm.portalAddParameter(&gpsHelp); - -PortalInfoSection battery; -battery.id = "battery"; -battery.title = "Battery"; -battery.items.push_back({"soc", "State of charge", "84%"}); -battery.items.push_back({"voltage", "Voltage", "13.2V"}); -wm.portalAddInfoSection(battery); - -PortalHomeCard solar; -solar.id = "solar"; -solar.title = "Solar summary"; -solar.kind = PortalHomeCardKind::KeyValue; -solar.items.push_back({"pv", "PV input", "420W"}); -solar.items.push_back({"load", "Load", "180W"}); -wm.portalAddHomeCard(solar); - -wm.portalAppendCss( - ".wm-brand-logo svg{width:40px;height:40px;display:block;}" - ".wm-hero-intro{max-width:42ch;}" -); - -wm.portalAppendJs( - "document.addEventListener('wm:ready', function(e){" - " console.log('Portal booted', e.detail.boot);" - "});" -); -``` - -## WiFi Connect Status API - -When `portalSetBehaviorConnectOnSave(true)` is enabled, saving WiFi credentials queues a station join and the SPA polls `GET /api/wifi/connect-status`. - -Response shape: - -```json -{ - "state": "waiting | success | failed", - "message": "human readable status", - "wifiStatus": "WL_CONNECTED", - "stationIp": "192.168.1.42", - "redirectUrl": "http://192.168.1.42/" -} -``` - -Notes: - -- `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. - -## Dependencies - -This fork depends on **DFTE** ([Device Framework Template Engine](https://github.com/alexhopeoconnor/DFTE)) and **ESP32Async/ESPAsyncWebServer**. - -The published `library.json` resolves DFTE from the maintained, pinned -`v1.0.2` Git tag. No sibling checkout is required to consume WiFiManager. -When developing both libraries together, use a local `symlink://` or -`file://` dependency in your own ignored PlatformIO override. - - -## Installation - -Use the release tag. 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.1.0 ``` -The text after `#` is a Git ref. PlatformIO clones the repository and checks -out that tag; it does not download a GitHub Release asset. A tag gives a -reproducible library input. Use a local checkout only while actively changing -the library: +The suffix after `#` is a Git ref. PlatformIO clones the repository and checks out that release tag; GitHub Release assets are unrelated. This repository is supported through PlatformIO. Use a local `symlink://` or `file://` dependency only while actively changing this fork. -```ini -lib_deps = - WiFiManager=file:///path/to/WiFiManager -``` - -## Tests and releases - -The CI workflow compiles a minimal clean consumer project for both supported -targets. These commands need no connected board: - -```bash -./scripts/compile-check.sh --platform esp8266 -./scripts/compile-check.sh --platform esp32 -``` - -Before publishing a version, update `library.json`, `CHANGELOG.md`, and this -README, run both checks, then create the annotated tag: +## Development and releases ```bash +./scripts/test.sh compile --platform esp8266 +./scripts/test.sh compile --platform esp32 +./scripts/check-docs.sh ./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag ``` -Push the branch and tag. GitHub Actions validates the PlatformIO package again -and creates the GitHub Release from that tag. +Tagging repeats the board-free compile checks, validates the package, and creates a GitHub Release from the corresponding changelog section. It does not publish to the PlatformIO Registry or deploy firmware. -## AI Assistance Notice - -Parts of this codebase have been developed and refactored with the aid of AI coding agents under human direction and review. - -## License - -See [LICENSE](LICENSE). +See the [documentation index](docs/README.md), [release history](CHANGELOG.md), and [licence](LICENSE). diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..5f9fa7c --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,21 @@ +# 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 +``` + +Before a release, update `library.json`, `CHANGELOG.md`, and the relevant public documentation, then run: + +```bash +./scripts/check-docs.sh +./scripts/test.sh compile --platform esp8266 +./scripts/test.sh compile --platform esp32 +./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag +``` + +Push the branch and annotated tag. GitHub Actions repeats the board-free compile checks, validates the package, and creates a GitHub Release using that version’s changelog section. The workflow does not publish to the PlatformIO Registry. + +Back to [documentation](README.md) · [project overview](../README.md). diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md new file mode 100644 index 0000000..26c8b3a --- /dev/null +++ b/docs/GETTING_STARTED.md @@ -0,0 +1,41 @@ +# Getting started + +WiFiManager owns station credential recovery and a temporary configuration portal. Keep one instance for the life of the application. + +```cpp +#include +#include + +WiFiManager wifi; + +void setup() { + Serial.begin(115200); + wifi.setConfigPortalTimeout(180); + if (!wifi.autoConnect("Example Setup", "change-me")) { + ESP.restart(); + } +} + +void loop() { + // Normal work begins after autoConnect succeeds. +} +``` + +`autoConnect()` first tries stored station credentials. If that cannot connect, it starts the AP and portal with the supplied name and password. Use a Wi-Fi-valid AP password. + +## PlatformIO dependency + +```ini +lib_deps = + WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.1.0 +``` + +The package manifest resolves DFTE, ESPAsyncWebServer, and the correct TCP transport for ESP8266 or ESP32. A consuming project should not duplicate those dependencies unless it is deliberately testing an unreleased stack change. + +## DeviceFramework applications + +DeviceFramework creates and configures WiFiManager for its normal lifecycle. Use DeviceFramework’s persistent shared device-password API instead of separately configuring an AP, OTA, HTTP, and WebSerial password. + +Next: [portal customisation](PORTAL_CUSTOMIZATION.md) or [portal API](PORTAL_API.md). + +Back to [documentation](README.md) · [project overview](../README.md). diff --git a/docs/PORTAL_API.md b/docs/PORTAL_API.md new file mode 100644 index 0000000..1827eef --- /dev/null +++ b/docs/PORTAL_API.md @@ -0,0 +1,34 @@ +# Portal API + +The portal uses JSON endpoints under `/api/...` for WiFi scans and saves, parameters, information, status, restart/erase/exit actions, captive-portal closure, and station-connect status. Consumers should treat the documented response shapes as the contract; portal HTML is not an API. + +## Profile-mode WiFi metadata + +When an application supplies a `WiFiManagerStationProfileStore`, `GET /api/wifi/meta` reports a primary profile and optional fallback without returning passwords. `POST /api/wifi/save` verifies a submitted candidate before committing it; see [station profiles](STATION_PROFILES.md) for fields and lifecycle. + +## WiFi connect status + +When `portalSetBehaviorConnectOnSave(true)` is enabled, saving credentials queues a station join. The SPA polls `GET /api/wifi/connect-status`: + +```json +{ + "state": "waiting | success | failed", + "message": "human readable status", + "wifiStatus": "WL_CONNECTED", + "stationIp": "192.168.1.42", + "redirectUrl": "http://192.168.1.42/" +} +``` + +`stationIp` and `redirectUrl` are present only after a successful join. If the portal server is not on port 80, `redirectUrl` includes that port. + +On success, WiFiManager keeps the portal alive briefly so the client can read the final status and navigate before the AP is shut down. Captive-portal helpers, DHCP timing, browser behaviour, and network isolation can still prevent an automatic redirect, so clients must handle a visible address as well. + +## API design rules + +- Use JSON endpoint results for state and actions. +- Keep UI-visible capabilities in the portal bootstrap/API payloads. +- Add a documented endpoint contract before adding a new portal workflow. +- Do not derive state by parsing the rendered shell. + +Back to [documentation](README.md) · [project overview](../README.md). diff --git a/docs/PORTAL_CUSTOMIZATION.md b/docs/PORTAL_CUSTOMIZATION.md new file mode 100644 index 0000000..7744c29 --- /dev/null +++ b/docs/PORTAL_CUSTOMIZATION.md @@ -0,0 +1,65 @@ +# Portal customisation + +The stable extension surface is the `portal*` API and JSON API contract. Templates, undocumented DOM IDs, and SPA routing internals are not consumer extension points. + +## Supported changes + +- `portalSetBrand*` and `portalSetContextIdentityText()` for the visible identity. +- `portalSetPage*`, `portalSetAction*`, `portalSetLayout*`, and `portalSetBehavior*` for built-in capability and layout choices. +- `portalAddParameter()`, including raw HTML blocks inside parameter-rendering surfaces. +- `portalAddInfoSection()` and `portalAddHomeCard()` for structured content. +- `portalAppendCss()`, `portalOverrideCss()`, and `portalAppendJs()` for small presentation enhancements. + +```cpp +WiFiManager wm; + +wm.portalSetBrandTitle("Solar Battery Monitor Setup"); +wm.portalSetContextIdentityText("Solar Battery Monitor"); +wm.portalSetBrandHomeIntro( + "Connect the monitor to WiFi, then review its battery settings." +); +wm.portalSetPageUpdateVisible(false); +wm.portalSetActionEraseVisible(false); +wm.portalSetLayoutParamsLocation(PortalParamsLocation::SetupPage); + +WiFiManagerParameter mqttHost( + "mqtt_host", "MQTT host", "broker.local", 64, + "placeholder='broker.local'" +); +wm.portalAddParameter(&mqttHost); + +PortalHomeCard summary; +summary.id = "battery"; +summary.title = "Battery"; +summary.kind = PortalHomeCardKind::KeyValue; +summary.items.push_back({"voltage", "Voltage", "13.2V"}); +wm.portalAddHomeCard(summary); + +wm.portalAppendJs( + "document.addEventListener('wm:ready', function(event) {" + " console.log('Portal ready', event.detail.boot);" + "});" +); +``` + +## Enhancement events + +Appended JavaScript may enhance the existing SPA through: + +- `wm:ready`, with `event.detail.boot`; +- `wm:view-changed`, with `event.detail.route`. + +It should not replace routing or built-in action flow. + +## Intentional boundary + +The following are fork-level changes, not supported consumer customisation: + +- arbitrary home, information, navigation, or shell HTML injection; +- replacing built-in SPA routes or action flows; +- depending on undocumented DOM IDs or private templates; +- adding a new backend-to-frontend workflow without an API contract. + +For data and endpoint behaviour, see [Portal API](PORTAL_API.md). + +Back to [documentation](README.md) · [project overview](../README.md). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..fdb643a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,20 @@ +# WiFiManager documentation + +This maintained fork intentionally has a narrower, explicit customisation boundary than upstream WiFiManager. + +| I want to… | Read | +| --- | --- | +| Start a basic portal or install a released dependency | [Getting started](GETTING_STARTED.md) | +| Brand the SPA, add parameters/cards, or use the supported hooks | [Portal customisation](PORTAL_CUSTOMIZATION.md) | +| Configure primary/fallback station profiles or their portal workflow | [Station profiles](STATION_PROFILES.md) | +| Consume JSON endpoints or station-connect status | [Portal API](PORTAL_API.md) | +| Build a clean consumer | [Testing](TESTING.md) | +| Work on this fork or make a release | [Development and releases](DEVELOPMENT.md) | + +## Documentation rules + +- Portal customisation owns the supported `portal*` surface. +- Portal API owns HTTP response contracts. +- The root README is a short onboarding page, not an API reference. + +Back to the [project overview](../README.md). diff --git a/docs/STATION_PROFILES.md b/docs/STATION_PROFILES.md new file mode 100644 index 0000000..3cb4ea6 --- /dev/null +++ b/docs/STATION_PROFILES.md @@ -0,0 +1,62 @@ +# Station profiles + +WiFiManager 3.1.0 adds an opt-in station-profile controller for applications that need a primary Wi-Fi network and one fallback. It is independent of the legacy `autoConnect()` flow: existing WiFiManager consumers do not need to change. + +DeviceFramework enables this controller and supplies its CRC-protected storage. A direct WiFiManager consumer supplies its own durable store, or can use the controller only for the current process. + +## Lifecycle + +A profile set has exactly two fixed slots: + +- **Primary** (slot 0) is required whenever the controller is enabled. +- **Fallback** (slot 1) is optional. +- The last successful slot is tried first on the next connection cycle, then the remaining enabled slot. + +The controller never treats the ESP SDK's saved single network as an additional source of truth. It begins one bounded connection attempt at a time, moves to the fallback after failure, and retries both profiles after a temporary loss of a previously working connection. If a new device has no valid profiles, it opens the normal configuration portal. + +A candidate submitted by the portal is only committed after it connects and receives a usable IP address. A failed candidate leaves the last saved profile set intact. + +## Direct WiFiManager use + +Implement a small store appropriate to the application. The manager neither allocates nor owns it: + +```cpp +class MyProfileStore final : public WiFiManagerStationProfileStore { +public: + bool load(WiFiManagerStationProfiles& profiles) override; + bool save(const WiFiManagerStationProfiles& profiles) override; + bool clear() override; +}; + +WiFiManager wifi; +MyProfileStore profiles; + +void setup() { + wifi.setStationProfileStore(&profiles); + wifi.setStationRecoveryInterval(30000); + wifi.startStationConnection("Example Setup", "setup-password"); +} + +void loop() { + wifi.process(); +} +``` + +The store must return a complete `WiFiManagerStationProfiles` value. Each enabled profile has a NUL-terminated SSID of at most 32 characters and an optional NUL-terminated password of at most 64 characters. Keep slot 0 enabled; set `hasPassword = false` for an open network. + +To stage profiles supplied by another subsystem, use `startStationCandidate(candidate)`. It connects the primary/fallback set first and calls the store only after success. Use `saveStationProfiles(profiles)` only when deliberately saving without a connection check. `clearStationProfiles()` clears the supplied store and disconnects the station. + +## Portal contract + +In profile mode, the existing Wi-Fi page becomes a two-profile form. It remains driven by the same JSON endpoints: + +- `GET /api/wifi/meta` returns `profiles`, `activeSlot`, and controller `state`. +- `POST /api/wifi/save` accepts `s0`/ `p0` for primary and `s1`/ `p1` for fallback. A blank submitted password preserves an existing password; send `clear0` or `clear1` for an intentional open network. +- A normal save verifies the candidate by connecting. Poll `GET /api/wifi/connect-status` for its result. +- Set `stationAction=save` only to store the submitted profiles for a later connection attempt. + +The portal requires a non-empty primary SSID. Its API never returns a password; it only reports whether one is set. + +See [Portal API](PORTAL_API.md) for the shared connection-status response and [DeviceFramework configuration](https://github.com/alexhopeoconnor/DeviceFramework/blob/main/docs/CONFIGURATION.md) for the local-profile JSON that supplies these slots. + +Back to [documentation](README.md) · [project overview](../README.md). diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..e6b4439 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,12 @@ +# Testing + +The clean-consumer check builds a project that declares only WiFiManager. It proves the package manifest resolves DFTE, ESPAsyncWebServer, and the correct ESP8266 or ESP32 TCP dependency without a sibling checkout or attached board. + +```bash +./scripts/test.sh compile --platform esp8266 +./scripts/test.sh compile --platform esp32 +``` + +CI runs both checks for pushes to the maintained branch and pull requests. They compile only; hardware portals remain a local integration concern for an application or DeviceFramework’s connected-device suite. + +Back to [documentation](README.md) · [project overview](../README.md). diff --git a/lib/WiFiManager/include/WiFiManager.h b/lib/WiFiManager/include/WiFiManager.h index f7f94c3..80e4791 100644 --- a/lib/WiFiManager/include/WiFiManager.h +++ b/lib/WiFiManager/include/WiFiManager.h @@ -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 #include +#include #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 #endif - #define WIFI_getChipId() ESP.getChipId() + #define WIFI_getChipId() ESP.getChipId() #define WM_WIFIOPEN ENC_TYPE_NONE #elif defined(ESP32) #include - #include + #include #include #include - + #define WIFI_getChipId() (uint32_t)ESP.getEfuseMac() #define WM_WIFIOPEN WIFI_AUTH_OPEN @@ -142,12 +143,44 @@ // Include utility functions #include "WiFiManagerUtils.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 #ifdef ESP_IDF_VERSION @@ -155,7 +188,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 +217,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 @@ -327,6 +360,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 +388,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; - + WiFiManager(Print& consolePort); WiFiManager(); ~WiFiManager(); @@ -347,14 +407,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 +512,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 +537,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,13 +587,13 @@ 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); @@ -563,10 +637,10 @@ class WiFiManager // 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 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 _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) @@ -808,7 +899,7 @@ class WiFiManager PortalStructuredExtrasState _portalStructured; // 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 +910,22 @@ class WiFiManager // async enables asyncronous scans, so they do not block anything // the refresh button bypasses cache // no aps found is problematic as scans are always going to want to run, leading to page load delays - // + // // These settings really only make sense with _preloadwifiscan true // but not limited to, we could run continuous background scans on various page hits, or xhr hits // which would be better coupled with asyncscan // atm preload is only done on root hit and startcp - // + // // preload scanning causes AP to delay showing for users, but also caches and lets the cp load faster once its open // my scan takes 7-10 seconds public: boolean _preloadwifiscan = false; // preload wifiscan if true unsigned int _scancachetime = 30000; // ms cache time for preload scans - + protected: boolean _autoforcerescan = false; // automatically force rescan if scan networks is 0, ignoring cache - + boolean _disableIpFields = false; // modify function of setShow_X_Fields(false), forces ip fields off instead of default show if set, eg. _staShowStaticFields=-1 String _wificountry = ""; // country code, @todo define in strings lang @@ -848,7 +939,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 +960,23 @@ protected: bool wifiConnectDefault(); bool wifiConnectNew(String ssid, String pass,bool connect = true); + void processStationController(); + bool beginStationProfile(uint8_t slot); + void queueStationProfile(uint8_t slot); + void beginStationCycle(bool preferLastSuccessful); + void handleStationAttemptFailure(uint8_t status, const String& message); + void handleStationConnectionSuccess(); + void completePortalStationAttempt(bool success, uint8_t status, const String& message); + void enterStationPortal(); + bool hasUsableStationConnection() const; + bool isStationProfileEnabled(const WiFiManagerStationProfiles& profiles, uint8_t slot) const; + uint8_t configuredStationProfileCount(const WiFiManagerStationProfiles& profiles) const; + uint8_t chooseStationProfile(const WiFiManagerStationProfiles& profiles, bool preferLastSuccessful) const; + const WiFiManagerStationProfiles& stationProfilesForAttempt() const; + WiFiManagerStationProfiles& stationProfilesForAttempt(); + bool validateStationProfiles(const WiFiManagerStationProfiles& profiles) const; + unsigned long stationAttemptTimeout() const; + uint8_t waitForConnectResult(); uint8_t waitForConnectResult(uint32_t timeout); void updateConxResult(uint8_t status); @@ -931,12 +1039,12 @@ protected: #define WM_DISCONWORKAROUND #endif - #else + #else #define WM_NOCOUNTRY #endif #ifdef WM_NOCOUNTRY - #warning "ESP32 set country unavailable" + #warning "ESP32 set country unavailable" #endif @@ -1042,9 +1150,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; diff --git a/lib/WiFiManager/include/WiFiManagerHandlers.h b/lib/WiFiManager/include/WiFiManagerHandlers.h index f0ebcda..3d78ca0 100644 --- a/lib/WiFiManager/include/WiFiManagerHandlers.h +++ b/lib/WiFiManager/include/WiFiManagerHandlers.h @@ -101,6 +101,7 @@ class WiFiManagerHandlers { void appendVisibleScanResultsJson(String& json, const std::vector& 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); diff --git a/lib/WiFiManager/include/templates/PortalAppJS.h b/lib/WiFiManager/include/templates/PortalAppJS.h index a59a5d3..f0530bb 100644 --- a/lib/WiFiManager/include/templates/PortalAppJS.h +++ b/lib/WiFiManager/include/templates/PortalAppJS.h @@ -465,6 +465,14 @@ function bindWifiViewEvents(){ if(p)p.type=this.checked?'text':'password'; }); } + var profileShowPass=document.querySelectorAll('.wm-showpass'); + var profileIndex; + for(profileIndex=0;profileIndex

"+esc(hint)+"

"; + html+=""; + html+=""; + html+=""; + html+=""; + html+="
"; + if(p.passwordSet){ + html+="
"; + } + html+=''; + } + return html; +} + function viewWifi(){ setView(navBar('wifi')+"

Loading WiFi options…

"); api('/api/wifi/meta').then(function(res){ @@ -502,17 +534,25 @@ function viewWifi(){ html+=""; html+="
"+cardTitleHtml('wifi','Network details'); html+="
"; - html+=renderFieldList(m.wifiFields||[]); - var wf=m.wifiFields||[]; - var hasPass=false; - var i; - for(i=0;i
"; + html+="
"; + if(Array.isArray(m.profiles) && m.profiles.length){ + html+=""; + } + html+="
"; html+="
"; setView(html); bindWifiViewEvents(); @@ -598,6 +638,9 @@ function pollWifiConnectStatus(){ window.portalWifiSave=function(ev){ ev.preventDefault(); var fd=new FormData(document.getElementById('wm-wifi-form')); + if(ev.submitter && ev.submitter.name){ + fd.append(ev.submitter.name,ev.submitter.value||''); + } var msg=$('wm-wifi-msg'); if(msg)msg.innerHTML='Submitting...'; api('/api/wifi/save',{method:'POST',body:fd}).then(function(res){ diff --git a/lib/WiFiManager/src/WiFiManager.cpp b/lib/WiFiManager/src/WiFiManager.cpp index 8543930..3b6b5b6 100644 --- a/lib/WiFiManager/src/WiFiManager.cpp +++ b/lib/WiFiManager/src/WiFiManager.cpp @@ -27,6 +27,7 @@ uint8_t WiFiManager::_lastconxresulttmp = WL_IDLE_STATUS; namespace { constexpr size_t kMaxHostnameLength = 32; constexpr uint8_t kSoftApStartMaxAttempts = 3; +constexpr unsigned long kStationProfileSwitchDelayMs = 2000UL; // ESP WiFi needs time to leave a failed association. bool isValidHostnameChar(char c) { return isAlphaNumeric(c) || c == '-'; @@ -197,6 +198,465 @@ void WiFiManager::_end(){ if(_userpersistent) WiFi.persistent(true); // reenable persistent, there is no getter we rely on _userpersistent } +void WiFiManager::setStationProfileStore(WiFiManagerStationProfileStore* store) { + _stationProfileStore = store; + _stationProfilesLoaded = false; + _stationCandidateActive = false; + _stationCandidateFromPortal = false; + _stationAttemptMask = 0; + _stationPendingSlot = WM_NO_STATION_PROFILE; + _stationNextAttemptAt = 0; + _stationStatus = wm_station_status_t(); +} + +bool WiFiManager::isStationProfileMode() const { + return _stationProfileStore != nullptr; +} + +void WiFiManager::setStationRecoveryInterval(unsigned long intervalMs) { + if (intervalMs > 0) { + _stationRecoveryInterval = intervalMs; + } +} + +const WiFiManagerStationProfiles& WiFiManager::getStationProfiles() const { + return _stationProfiles; +} + +const WiFiManager::wm_station_status_t& WiFiManager::getStationStatus() const { + return _stationStatus; +} + +bool WiFiManager::isStationProfileEnabled(const WiFiManagerStationProfiles& profiles, uint8_t slot) const { + return slot < WM_STATION_PROFILE_COUNT && profiles.slots[slot].enabled && profiles.slots[slot].ssid[0] != '\0'; +} + +uint8_t WiFiManager::configuredStationProfileCount(const WiFiManagerStationProfiles& profiles) const { + uint8_t count = 0; + for (uint8_t slot = 0; slot < WM_STATION_PROFILE_COUNT; ++slot) { + if (isStationProfileEnabled(profiles, slot)) { + ++count; + } + } + return count; +} + +bool WiFiManager::validateStationProfiles(const WiFiManagerStationProfiles& profiles) const { + if (profiles.preferredSlot >= WM_STATION_PROFILE_COUNT || !isStationProfileEnabled(profiles, 0)) { + return false; + } + + for (uint8_t slot = 0; slot < WM_STATION_PROFILE_COUNT; ++slot) { + const WiFiManagerStationProfile& profile = profiles.slots[slot]; + if (!profile.enabled) { + continue; + } + if (memchr(profile.ssid, '\0', sizeof(profile.ssid)) == nullptr || + memchr(profile.password, '\0', sizeof(profile.password)) == nullptr) { + return false; + } + } + + return profiles.lastSuccessfulSlot == WM_NO_STATION_PROFILE || + isStationProfileEnabled(profiles, profiles.lastSuccessfulSlot); +} + +uint8_t WiFiManager::chooseStationProfile(const WiFiManagerStationProfiles& profiles, + bool preferLastSuccessful) const { + if (preferLastSuccessful && isStationProfileEnabled(profiles, profiles.lastSuccessfulSlot)) { + return profiles.lastSuccessfulSlot; + } + if (isStationProfileEnabled(profiles, profiles.preferredSlot)) { + return profiles.preferredSlot; + } + for (uint8_t slot = 0; slot < WM_STATION_PROFILE_COUNT; ++slot) { + if (isStationProfileEnabled(profiles, slot)) { + return slot; + } + } + return WM_NO_STATION_PROFILE; +} + +const WiFiManagerStationProfiles& WiFiManager::stationProfilesForAttempt() const { + return _stationCandidateActive ? _stationCandidate : _stationProfiles; +} + +WiFiManagerStationProfiles& WiFiManager::stationProfilesForAttempt() { + return _stationCandidateActive ? _stationCandidate : _stationProfiles; +} + +unsigned long WiFiManager::stationAttemptTimeout() const { + // The synchronous legacy connection path allows an unlimited timeout. A + // profile controller must stay bounded so it can advance to the fallback. + return _connectTimeout > 0 ? _connectTimeout : 15000UL; +} + +bool WiFiManager::hasUsableStationConnection() const { + if (WiFi.status() != WL_CONNECTED) { + return false; + } + return WiFi.localIP() != IPAddress(0, 0, 0, 0); +} + +bool WiFiManager::startStationConnection(char const *apName, char const *apPassword) { + if (!isStationProfileMode()) { + return false; + } + + _begin(); + _stationPortalApName = apName ? apName : getDefaultAPName(); + _stationPortalApPassword = apPassword ? apPassword : ""; + _stationCandidateActive = false; + _stationCandidateFromPortal = false; + _stationAttemptMask = 0; + _stationPendingSlot = WM_NO_STATION_PROFILE; + _stationNextAttemptAt = 0; + _stationStatus = wm_station_status_t(); + _stationStatus.state = WM_STATION_LOADING; + _stationStatus.message = F("Loading WiFi profiles"); + + WiFiManagerStationProfiles loaded; + if (_stationProfileStore->load(loaded)) { + _stationProfiles = loaded; + } else { + _stationProfiles = WiFiManagerStationProfiles(); + } + _stationProfilesLoaded = true; + _stationStatus.configuredProfiles = configuredStationProfileCount(_stationProfiles); + + if (!validateStationProfiles(_stationProfiles)) { + _stationStatus.message = _stationStatus.configuredProfiles == 0 + ? F("No WiFi profiles configured") + : F("Stored WiFi profiles are invalid"); + enterStationPortal(); + return false; + } + + beginStationCycle(true); + return true; +} + +bool WiFiManager::startStationCandidate(const WiFiManagerStationProfiles& candidate) { + if (!validateStationProfiles(candidate)) { + _stationStatus.state = WM_STATION_IDLE; + _stationStatus.message = F("WiFi profile candidate is invalid"); + return false; + } + + if (!_stationProfilesLoaded && _stationProfileStore) { + WiFiManagerStationProfiles loaded; + _stationProfiles = _stationProfileStore->load(loaded) + ? loaded + : WiFiManagerStationProfiles(); + _stationProfilesLoaded = true; + } + + _begin(); + _stationCandidate = candidate; + _stationCandidateActive = true; + _stationCandidateFromPortal = configPortalActive; + _stationAttemptMask = 0; + _stationPendingSlot = WM_NO_STATION_PROFILE; + _stationNextAttemptAt = 0; + _stationStatus = wm_station_status_t(); + _stationStatus.state = WM_STATION_LOADING; + _stationStatus.configuredProfiles = configuredStationProfileCount(candidate); + _stationStatus.message = F("WiFi profile candidate queued"); + + if (_stationCandidateFromPortal) { + _cpConnectStatus = WL_IDLE_STATUS; + _cpConnectStationIp = ""; + _cpConnectState = wm_cp_connect_state_t::queued; + _cpConnectMessage = F("WiFi profile candidate queued"); + emitPortalEvent(WM_EVENT_PORTAL_CONNECT_QUEUED); + } + + beginStationCycle(false); + return true; +} + +bool WiFiManager::startStationCandidate(const WiFiManagerStationProfiles& candidate, + char const *apName, char const *apPassword) { + _stationPortalApName = apName ? apName : getDefaultAPName(); + _stationPortalApPassword = apPassword ? apPassword : ""; + return startStationCandidate(candidate); +} + +bool WiFiManager::saveStationProfiles(const WiFiManagerStationProfiles& profiles) { + if (!isStationProfileMode() || !validateStationProfiles(profiles)) { + return false; + } + if (!_stationProfileStore->save(profiles)) { + _stationStatus.storageSaveFailed = true; + _stationStatus.message = F("WiFi profiles could not be saved"); + return false; + } + + _stationProfiles = profiles; + _stationProfilesLoaded = true; + _stationStatus.configuredProfiles = configuredStationProfileCount(_stationProfiles); + _stationStatus.storageSaveFailed = false; + _stationStatus.message = F("WiFi profiles saved"); + return true; +} + +void WiFiManager::clearStationProfiles() { + if (_stationProfileStore && !_stationProfileStore->clear()) { + _stationStatus.storageSaveFailed = true; + _stationStatus.message = F("WiFi profiles could not be cleared"); + return; + } + + WiFi_Disconnect(); + WiFi_eraseConfig(); + _stationProfiles = WiFiManagerStationProfiles(); + _stationCandidate = WiFiManagerStationProfiles(); + _stationCandidateActive = false; + _stationCandidateFromPortal = false; + _stationProfilesLoaded = false; + _stationAttemptMask = 0; + _stationPendingSlot = WM_NO_STATION_PROFILE; + _stationNextAttemptAt = 0; + _stationEverConnected = false; + _stationStatus = wm_station_status_t(); + _stationStatus.message = F("WiFi profiles cleared"); + emitPortalEvent(WM_EVENT_STATION_PROFILES_CLEARED); +} + +void WiFiManager::beginStationCycle(bool preferLastSuccessful) { + _stationAttemptMask = 0; + _stationPendingSlot = WM_NO_STATION_PROFILE; + _stationNextAttemptAt = 0; + const uint8_t first = chooseStationProfile(stationProfilesForAttempt(), preferLastSuccessful); + if (first == WM_NO_STATION_PROFILE) { + enterStationPortal(); + return; + } + beginStationProfile(first); +} + +bool WiFiManager::beginStationProfile(uint8_t slot) { + const WiFiManagerStationProfiles& profiles = stationProfilesForAttempt(); + if (!isStationProfileEnabled(profiles, slot)) { + return false; + } + + const WiFiManagerStationProfile& profile = profiles.slots[slot]; + _stationPendingSlot = WM_NO_STATION_PROFILE; + _stationNextAttemptAt = 0; + _stationAttemptMask |= static_cast(1U << slot); + _stationAttemptStartedAt = millis(); + _stationStatus.state = WM_STATION_ATTEMPTING; + _stationStatus.attemptedSlot = slot; + _stationStatus.wifiStatus = WL_IDLE_STATUS; + _stationStatus.message = String(F("Connecting profile ")) + String(slot + 1); + emitPortalEvent(WM_EVENT_STATION_PROFILE_ATTEMPT); + + WiFi.persistent(false); + WiFi.setAutoReconnect(false); + WiFi_enableSTA(true, false); + setSTAConfig(); + if (_cleanConnect) { + WiFi_Disconnect(); + } + + WiFi.begin( + profile.ssid, + profile.hasPassword ? profile.password : nullptr); + return true; +} + +void WiFiManager::queueStationProfile(uint8_t slot) { + _stationPendingSlot = slot; + _stationNextAttemptAt = millis() + kStationProfileSwitchDelayMs; + _stationStatus.state = WM_STATION_SWITCHING; + _stationStatus.message = String(F("Switching to profile ")) + String(slot + 1); + WiFi_Disconnect(); +} + +void WiFiManager::completePortalStationAttempt(bool success, uint8_t status, const String& message) { + if (!_stationCandidateFromPortal && !configPortalActive) { + return; + } + + _cpConnectStatus = status; + _cpConnectMessage = message; + _cpConnectStationIp = success ? WiFi.localIP().toString() : ""; + _cpConnectState = success ? wm_cp_connect_state_t::success : wm_cp_connect_state_t::failed; + + if (success) { + if (_savewificallback != NULL) { + _savewificallback(); + } + emitPortalEvent(WM_EVENT_PORTAL_CONNECT_SUCCESS); + if (_disableConfigPortal) { + shutdownConfigPortal(); + } + } else { + updateConxResult(status); + emitPortalEvent(WM_EVENT_PORTAL_CONNECT_FAILED); + } +} + +void WiFiManager::handleStationConnectionSuccess() { + const uint8_t slot = _stationStatus.attemptedSlot; + const bool wasCandidate = _stationCandidateActive; + const bool wasPortalCandidate = _stationCandidateFromPortal; + bool persisted = true; + + if (wasCandidate) { + const WiFiManagerStationProfiles previous = _stationProfiles; + _stationCandidate.lastSuccessfulSlot = slot; + if (_stationProfileStore) { + persisted = _stationProfileStore->save(_stationCandidate); + } + if (persisted) { + _stationProfiles = _stationCandidate; + _stationProfilesLoaded = true; + } else { + _stationProfiles = previous; + } + _stationCandidateActive = false; + _stationCandidateFromPortal = false; + } else { + const bool changed = _stationProfiles.lastSuccessfulSlot != slot; + _stationProfiles.lastSuccessfulSlot = slot; + if (changed && _stationProfileStore) { + persisted = _stationProfileStore->save(_stationProfiles); + } + } + + _stationEverConnected = true; + _stationStatus.state = WM_STATION_CONNECTED; + _stationStatus.activeSlot = slot; + _stationStatus.wifiStatus = WL_CONNECTED; + _stationStatus.lastConnectionWasCandidate = wasCandidate; + _stationStatus.storageSaveFailed = !persisted; + _stationStatus.message = persisted ? F("WiFi connected") : F("WiFi connected but profiles could not be saved"); + emitPortalEvent(WM_EVENT_STATION_PROFILE_CONNECTED); + + if (wasPortalCandidate) { + completePortalStationAttempt(persisted, persisted ? WL_CONNECTED : WL_CONNECT_FAILED, + _stationStatus.message); + } +} + +void WiFiManager::enterStationPortal() { + _stationStatus.state = WM_STATION_PORTAL; + if (_stationStatus.message.length() == 0) { + _stationStatus.message = F("WiFi configuration required"); + } + + if (configPortalActive || !_enableConfigPortal) { + return; + } + + String apName = _stationPortalApName.length() ? _stationPortalApName : getDefaultAPName(); + startConfigPortal(apName.c_str(), _stationPortalApPassword.length() ? _stationPortalApPassword.c_str() : NULL); +} + +void WiFiManager::handleStationAttemptFailure(uint8_t status, const String& message) { + _stationStatus.wifiStatus = status; + _stationStatus.message = message; + const WiFiManagerStationProfiles& profiles = stationProfilesForAttempt(); + + for (uint8_t slot = 0; slot < WM_STATION_PROFILE_COUNT; ++slot) { + const uint8_t bit = static_cast(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(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); @@ -656,7 +1116,10 @@ boolean WiFiManager::process(){ #endif processScan(); - processPortalConnect(); + processStationController(); + if (!_stationCandidateFromPortal) { + processPortalConnect(); + } if(webPortalActive || configPortalActive){ // if timed out or abort, break @@ -813,7 +1276,8 @@ void WiFiManager::processPortalConnect() { } return; } - if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL || status == WL_CONNECTION_LOST) { + if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL || + status == WL_CONNECTION_LOST) { failPortalConnect(status, getWLStatusString(status)); return; } diff --git a/lib/WiFiManager/src/WiFiManagerHandlers.cpp b/lib/WiFiManager/src/WiFiManagerHandlers.cpp index 0a483c3..4a8604d 100644 --- a/lib/WiFiManager/src/WiFiManagerHandlers.cpp +++ b/lib/WiFiManager/src/WiFiManagerHandlers.cpp @@ -783,6 +783,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(); @@ -1193,6 +1249,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 +1338,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, diff --git a/library.json b/library.json index 74be409..6612600 100644 --- a/library.json +++ b/library.json @@ -1,6 +1,6 @@ { "name": "WiFiManager", - "version": "3.0.6", + "version": "3.1.0", "keywords": [ "wifi", "wi-fi", @@ -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", diff --git a/scripts/check-docs.sh b/scripts/check-docs.sh new file mode 100755 index 0000000..e7b7003 --- /dev/null +++ b/scripts/check-docs.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +failed=0 + +link_pattern='\]\(([^ )]+)' +while IFS= read -r file; do + in_fence=false + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" =~ ^[[:space:]]*\`\`\` ]]; then + [[ "$in_fence" == true ]] && in_fence=false || in_fence=true + continue + fi + [[ "$in_fence" == true ]] && continue + remainder="$line" + while [[ "$remainder" =~ $link_pattern ]]; do + target="${BASH_REMATCH[1]}" + remainder="${remainder#*]($target)}" + case "$target" in + \#*|http://*|https://*|mailto:*|tel:*) continue ;; + esac + target="${target%%#*}" + [[ -z "$target" ]] && continue + if [[ "$target" == /* ]]; then + candidate="$root/${target#/}" + else + candidate="$(dirname "$file")/$target" + fi + if [[ ! -e "$candidate" ]]; then + printf 'Broken local Markdown link: %s -> %s\n' "${file#$root/}" "$target" >&2 + failed=1 + fi + done + done < "$file" +done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -type f -name '*.md' -print) + +for required in README.md CHANGELOG.md docs/README.md docs/GETTING_STARTED.md docs/PORTAL_CUSTOMIZATION.md docs/PORTAL_API.md docs/TESTING.md docs/DEVELOPMENT.md; do + if [[ ! -f "$root/$required" ]]; then + printf 'Missing required documentation file: %s\n' "$required" >&2 + failed=1 + fi +done + +if [[ "$failed" -ne 0 ]]; then + exit 1 +fi + +echo "WiFiManager documentation checks passed" diff --git a/scripts/prepare-release.sh b/scripts/prepare-release.sh index e371f99..b3a7873 100755 --- a/scripts/prepare-release.sh +++ b/scripts/prepare-release.sh @@ -19,6 +19,11 @@ if [[ "$manifest_version" != "$version" ]]; then exit 1 fi +grep -q "^## $version$" "$root/CHANGELOG.md" || { + echo "CHANGELOG.md has no $version heading" >&2 + exit 1 +} + if [[ -f "$root/library.properties" ]]; then properties_version="$(sed -n 's/^version=//p' "$root/library.properties" | head -n 1)" if [[ "$properties_version" != "$version" ]]; then @@ -27,6 +32,7 @@ if [[ -f "$root/library.properties" ]]; then fi fi +git -C "$root" diff --check package_dir="$(mktemp -d)" trap 'rm -rf "$package_dir"' EXIT pio pkg pack "$root" --output "$package_dir/package.tar.gz" >/dev/null diff --git a/scripts/release-notes.sh b/scripts/release-notes.sh new file mode 100755 index 0000000..ffa621f --- /dev/null +++ b/scripts/release-notes.sh @@ -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" diff --git a/scripts/compile-check.sh b/scripts/test.sh similarity index 66% rename from scripts/compile-check.sh rename to scripts/test.sh index 99974de..2291382 100755 --- a/scripts/compile-check.sh +++ b/scripts/test.sh @@ -2,14 +2,13 @@ set -euo pipefail usage() { - echo "Usage: $0 --platform esp8266|esp32" >&2 + echo "Usage: $0 compile --platform esp8266|esp32" >&2 exit 2 } -[[ "${1:-}" == "--platform" ]] || usage -platform="${2:-}" +[[ "${1:-}" == "compile" && "${2:-}" == "--platform" && $# -eq 3 ]] || usage +platform="${3:-}" [[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage -[[ $# -eq 2 ]] || usage root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" pio run -d "$root/test/compile-project" -e "$platform"