mirror of
https://github.com/alexhopeoconnor/WiFiManager.git
synced 2026-10-04 02:48:13 +10:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
174c3045e5 | ||
|
|
83f7f8bfc8 | ||
|
|
360b7cff86 | ||
|
|
7c5b50499e | ||
|
|
3e09958d11 | ||
|
|
f1fdeec8ee | ||
|
|
4aab676574 | ||
|
|
d1ad84db5b | ||
|
|
babc58637f | ||
|
|
228e1252f4 | ||
|
|
7305ea8827 | ||
|
|
9fec8799ac | ||
|
|
6500eee528 | ||
|
|
50972914e3 | ||
|
|
4fede428c4 | ||
|
|
20e75622ac |
@@ -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*
|
||||
@@ -20,8 +20,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: bash -n scripts/*.sh tools/portal-hardware tools/lib/*.sh tools/tests/*.sh
|
||||
- run: ./tools/tests/test-portal-hardware-cli.sh
|
||||
- run: 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:
|
||||
@@ -29,12 +36,25 @@ jobs:
|
||||
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/test.sh compile --platform ${{ matrix.environment }}
|
||||
- run: ./scripts/test.sh examples --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,22 +6,63 @@ 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
|
||||
- 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 esp8266
|
||||
- run: ./scripts/test.sh examples --platform esp8266
|
||||
- run: ./scripts/test.sh compile --platform esp32
|
||||
- run: ./scripts/test.sh examples --platform esp32
|
||||
- run: ./scripts/check-docs.sh
|
||||
- run: ./scripts/prepare-release.sh "$GITHUB_REF_NAME"
|
||||
- run: ./scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md"
|
||||
- run: >-
|
||||
|
||||
+2
-1
@@ -36,4 +36,5 @@ node_modules/
|
||||
|
||||
# Local portal station handoff credentials and optional direct test artifacts
|
||||
/test/portal-station.env
|
||||
/tests/portal-contract/artifacts/
|
||||
/tests/portal-harness/artifacts/
|
||||
/artifacts/
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 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
|
||||
|
||||
@@ -4,10 +4,12 @@ WiFiManager gives ESP8266 and ESP32 firmware a polished, self-hosted Wi-Fi setup
|
||||
|
||||
## See it on real hardware
|
||||
|
||||
| Brand the built-in portal | Combine Wi-Fi and application setup |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
| Give each product its own title, identity, icon, and color theme without copying portal HTML. | Show live nearby networks and collect application values, such as an MQTT broker, in the same setup flow. |
|
||||

|
||||
|
||||
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.
|
||||
|
||||
## Start with a working portal
|
||||
|
||||
@@ -19,17 +21,30 @@ WiFiManager wifi;
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
// 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() {
|
||||
// Service portal requests and connection state without blocking firmware work.
|
||||
wifi.process();
|
||||
}
|
||||
```
|
||||
|
||||
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?
|
||||
|
||||
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.
|
||||
|
||||
## Make it yours
|
||||
|
||||
```cpp
|
||||
@@ -91,7 +106,7 @@ The [Branded Portal](examples/BrandedPortal/) example includes a static SVG, acc
|
||||
```ini
|
||||
[common]
|
||||
lib_deps =
|
||||
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.4
|
||||
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.5
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
+77
-8
@@ -7,12 +7,56 @@ lib_deps =
|
||||
WiFiManager=symlink:///path/to/WiFiManager
|
||||
```
|
||||
|
||||
The ESP32 environments pin the PlatformIO-compatible pioarduino 51.03.05
|
||||
platform package, which packages official Arduino-ESP32 3.0.5. This avoids the
|
||||
known six-second asynchronous scan failure in the older 2.0.17 framework. Core
|
||||
3 also requires the `SOC_WIFI_SUPPORTED`, `Network/src`, and ESP8266-transport
|
||||
ignore settings shown in this repository `platformio.ini`; keep those settings
|
||||
when adding an ESP32 environment.
|
||||
## 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:
|
||||
|
||||
@@ -22,6 +66,12 @@ Start a release with `bump-version.sh`. It updates package metadata and canonica
|
||||
./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
|
||||
```
|
||||
|
||||
@@ -33,7 +83,7 @@ When a physical ESP8266 and ESP32 are available, include their local lifecycle t
|
||||
~~~
|
||||
|
||||
When a physical ESP8266 or ESP32 and a spare USB Wi-Fi adapter are available,
|
||||
run the Docker portal contract as an additional release-gate check. It is
|
||||
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:
|
||||
@@ -43,9 +93,28 @@ for browser/API testing:
|
||||
--client-interface wlx74da385d4165
|
||||
```
|
||||
|
||||
See [Testing](TESTING.md#docker-portal-contract) for cleanup, artifacts, and
|
||||
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).
|
||||
|
||||
@@ -26,7 +26,7 @@ void loop() {
|
||||
|
||||
```ini
|
||||
lib_deps =
|
||||
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.4
|
||||
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.
|
||||
|
||||
+6
-2
@@ -65,7 +65,7 @@ A normal accepted save returns 202 and directs the portal to poll /api/wifi/conn
|
||||
|
||||
~~~json
|
||||
{
|
||||
"state": "idle | waiting | success | failed",
|
||||
"state": "success",
|
||||
"message": "human readable status",
|
||||
"wifiStatus": "WL_CONNECTED",
|
||||
"stationIp": "192.168.1.42",
|
||||
@@ -73,7 +73,11 @@ A normal accepted save returns 202 and directs the portal to poll /api/wifi/conn
|
||||
}
|
||||
~~~
|
||||
|
||||
stationIp and redirectUrl are present only after success. If WiFiManager uses a non-default HTTP port, redirectUrl includes it.
|
||||
`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.
|
||||
|
||||
|
||||
+11
-1
@@ -4,6 +4,16 @@ WiFiManagerPortalConfig is the supported presentation API for the built-in provi
|
||||
|
||||
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
|
||||
@@ -51,7 +61,7 @@ void setup() {
|
||||
void loop() { wifi.process(); }
|
||||
~~~
|
||||
|
||||
The complete buildable example is [Branded Portal](../examples/BrandedPortal/BrandedPortal.ino). The compile fixture exercises this API on ESP8266 and ESP32.
|
||||
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
|
||||
|
||||
|
||||
+28
-10
@@ -18,18 +18,36 @@ A candidate submitted by the portal or another application subsystem is only com
|
||||
|
||||
## Direct WiFiManager use
|
||||
|
||||
Implement a small store appropriate to the application. WiFiManager neither allocates nor owns it:
|
||||
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 MyProfileStore final : public WiFiManagerStationProfileStore {
|
||||
```cpp
|
||||
class MemoryProfileStore final : public WiFiManagerStationProfileStore {
|
||||
public:
|
||||
bool load(WiFiManagerStationProfiles& profiles) override;
|
||||
bool save(const WiFiManagerStationProfiles& profiles) override;
|
||||
bool clear() override;
|
||||
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;
|
||||
MyProfileStore profiles;
|
||||
MemoryProfileStore profiles; // Must outlive WiFiManager's asynchronous connection work.
|
||||
|
||||
void setup() {
|
||||
wifi.setStationProfileStore(&profiles);
|
||||
@@ -38,11 +56,11 @@ void setup() {
|
||||
}
|
||||
|
||||
void loop() {
|
||||
wifi.process();
|
||||
wifi.process(); // Advances profile retries and serves the fallback portal.
|
||||
}
|
||||
~~~
|
||||
```
|
||||
|
||||
The store must return a complete WiFiManagerStationProfiles value. Each enabled profile has a NUL-terminated SSID of at most 32 characters and an optional NUL-terminated password of at most 64 characters. Keep slot 0 enabled; set hasPassword to false for an open network.
|
||||
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.
|
||||
|
||||
|
||||
+225
-25
@@ -1,27 +1,54 @@
|
||||
# Testing
|
||||
|
||||
WiFiManager separates repeatable package checks from opt-in tests that flash a
|
||||
real board or join a captive portal. The normal commands never need a board,
|
||||
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.
|
||||
|
||||
## Clean consumer and example builds
|
||||
| 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 clean-consumer check builds a project that declares only WiFiManager. It
|
||||
proves the package manifest resolves DFTE, ESPAsyncWebServer, and the correct
|
||||
ESP8266 or ESP32 TCP dependency without a sibling checkout. The runner removes
|
||||
a prior local package link before each check, so dependency resolution uses the
|
||||
current manifest rather than a stale `.pio` copy.
|
||||
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. It intentionally does not require attached hardware, a local network,
|
||||
or Docker.
|
||||
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
|
||||
|
||||
@@ -40,17 +67,44 @@ pio device list
|
||||
The runner flashes the selected board, captures normal-boot serial output with
|
||||
the repository Bash helper, requires Unity's `Tests 0 Failures` and `OK`
|
||||
summary, and prints lifecycle metrics. Hardware work shares a lock with the
|
||||
portal contract, so two invocations cannot flash or use the same board at once.
|
||||
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 contract
|
||||
## 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 contract in a pinned Playwright
|
||||
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 \
|
||||
@@ -67,18 +121,36 @@ replaced:
|
||||
./tools/portal-hardware run ... --take-over-client-adapter
|
||||
```
|
||||
|
||||
The fixture opens a 15-minute portal session, includes one harmless custom
|
||||
parameter, and verifies root/bootstrap/info/status API responses, concurrent
|
||||
low-priority requests, parameter persistence, timeout reset, an actual async
|
||||
scan, a missing-route response, and desktop/mobile portal rendering with no
|
||||
browser page errors. Screenshots, traces on failure, JSON results, and the HTML
|
||||
report are saved under the printed XDG state-directory artifact path.
|
||||
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 contract deliberately retries
|
||||
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:
|
||||
|
||||
@@ -90,16 +162,144 @@ only that managed connection when finished:
|
||||
|
||||
An optional station handoff test is deliberately separate because it connects
|
||||
the fixture to a real LAN. Copy the ignored template below, add local
|
||||
credentials, and pass it explicitly; it is mounted read-only into the test
|
||||
container and is never logged by the runner.
|
||||
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.
|
||||
|
||||
A retained session is deliberately never overwritten. If a previous `up` or an
|
||||
interrupted `run` left one behind, run `./tools/portal-hardware down` first;
|
||||
that removes only the named temporary connection recorded by the tool.
|
||||
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.
|
Before Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB |
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 |
@@ -5,8 +5,9 @@ WiFiManager portal;
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
portal.setConfigPortalTimeout(180);
|
||||
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 {
|
||||
@@ -14,4 +15,6 @@ void setup() {
|
||||
}
|
||||
}
|
||||
|
||||
void loop() { portal.process(); }
|
||||
void loop() {
|
||||
portal.process(); // Keeps DNS, HTTP, and station-recovery work responsive.
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ platform_packages =
|
||||
|
||||
[env:esp32]
|
||||
extends = common
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
|
||||
board = esp32dev
|
||||
build_unflags = -std=gnu++11
|
||||
build_flags =
|
||||
|
||||
@@ -17,7 +17,7 @@ platform_packages =
|
||||
|
||||
[env:esp32]
|
||||
extends = common
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
|
||||
board = esp32dev
|
||||
build_unflags = -std=gnu++11
|
||||
build_flags =
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
#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);
|
||||
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;
|
||||
@@ -28,7 +29,7 @@ void setup() {
|
||||
portal.portalAddHomeCard(hint);
|
||||
|
||||
portal.setSaveParamsCallback([](WiFiManager::WiFiManagerRequestArgs) {
|
||||
// A product validates and persists this value here; this demo only prints it.
|
||||
// Validate and persist a copy in the application; this example only reports it.
|
||||
Serial.print("MQTT broker selected: ");
|
||||
Serial.println(brokerHost.getValue());
|
||||
});
|
||||
@@ -37,4 +38,6 @@ void setup() {
|
||||
portal.autoConnect("WiFiManager Content", "example-pass");
|
||||
}
|
||||
|
||||
void loop() { portal.process(); }
|
||||
void loop() {
|
||||
portal.process(); // Serves portal requests until provisioning completes or times out.
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ platform_packages =
|
||||
|
||||
[env:esp32]
|
||||
extends = common
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
|
||||
board = esp32dev
|
||||
build_unflags = -std=gnu++11
|
||||
build_flags =
|
||||
|
||||
@@ -10,10 +10,12 @@ struct StoredProfiles {
|
||||
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));
|
||||
@@ -25,6 +27,7 @@ public:
|
||||
StoredProfiles stored{};
|
||||
EEPROM.get(0, stored);
|
||||
if (stored.magic != kStoreMagic) {
|
||||
// Treat erased or unrelated EEPROM as having no profiles.
|
||||
return false;
|
||||
}
|
||||
profiles = stored.profiles;
|
||||
@@ -32,6 +35,7 @@ public:
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -53,11 +57,12 @@ void setup() {
|
||||
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();
|
||||
portal.process(); // Advances connection attempts and serves provisioning when needed.
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ platform_packages =
|
||||
|
||||
[env:esp32]
|
||||
extends = common
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
|
||||
board = esp32dev
|
||||
build_unflags = -std=gnu++11
|
||||
build_flags =
|
||||
|
||||
@@ -337,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;
|
||||
@@ -1061,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());
|
||||
|
||||
@@ -939,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)
|
||||
@@ -1003,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) {
|
||||
@@ -1604,4 +1615,3 @@ void WiFiManagerHandlers::handleApiPortalExit(AsyncWebServerRequest *request) {
|
||||
}
|
||||
|
||||
#endif // defined(ESP8266) || defined(ESP32)
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "WiFiManager",
|
||||
"version": "3.2.4",
|
||||
"version": "3.2.5",
|
||||
"keywords": [
|
||||
"wifi",
|
||||
"wi-fi",
|
||||
|
||||
+3
-4
@@ -16,11 +16,11 @@ build_flags =
|
||||
-DUNIT_TEST
|
||||
lib_deps =
|
||||
ESP32Async/ESPAsyncWebServer@3.9.1
|
||||
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.0
|
||||
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.1
|
||||
ESP32Async/ESPAsyncTCP@2.0.0
|
||||
|
||||
[env:esp32]
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
|
||||
board = esp32dev
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
@@ -36,7 +36,7 @@ build_flags =
|
||||
-DUNIT_TEST
|
||||
lib_deps =
|
||||
ESP32Async/ESPAsyncWebServer@3.9.1
|
||||
DeviceFrameworkTemplateEngine=https://github.com/alexhopeoconnor/DFTE.git#v1.2.0
|
||||
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)
|
||||
@@ -45,4 +45,3 @@ extends = env:esp8266
|
||||
build_flags =
|
||||
${env:esp8266.build_flags}
|
||||
-DWM_DFTE_LOGGING
|
||||
|
||||
|
||||
@@ -4,6 +4,32 @@ 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
|
||||
@@ -33,6 +59,7 @@ while IFS= read -r file; do
|
||||
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
|
||||
@@ -42,6 +69,35 @@ for required in README.md CHANGELOG.md docs/README.md docs/GETTING_STARTED.md do
|
||||
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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -54,7 +56,7 @@ 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
|
||||
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
|
||||
|
||||
+123
-19
@@ -1,18 +1,24 @@
|
||||
#!/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 examples --platform esp8266|esp32
|
||||
./scripts/test.sh hardware --platform esp8266|esp32 --port /dev/serial/by-id/...
|
||||
./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" == "examples" || "$mode" == "hardware" ]] || usage
|
||||
[[ "$mode" == "compile" || "$mode" == "unity" || "$mode" == "examples" || "$mode" == "ota-fixtures" || "$mode" == "hardware" ]] || usage
|
||||
shift
|
||||
|
||||
platform=""
|
||||
@@ -25,16 +31,66 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
|
||||
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
|
||||
# Keep serial flashing and portal-adapter work mutually exclusive.
|
||||
# shellcheck source=tools/lib/portal-hardware-session.sh
|
||||
source "$root/tools/lib/portal-hardware-session.sh"
|
||||
wm_acquire_hardware_lock
|
||||
# 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
|
||||
@@ -44,29 +100,77 @@ if [[ "$mode" == "examples" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
for example in "${examples[@]}"; do
|
||||
pio run -d "$example" -e "$platform" </dev/null
|
||||
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 test -d "$root" -e "$platform" --filter test_wifimanager \
|
||||
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
|
||||
|
||||
cached_library="$root/test/compile-project/.pio/libdeps/${platform}/WiFiManager"
|
||||
# The fixture intentionally declares only this local package. Remove a prior
|
||||
# link so each check resolves the current manifest as a fresh consumer would.
|
||||
if [[ -d "$cached_library" || -e "${cached_library}.pio-link" ]]; then
|
||||
pio pkg uninstall -d "$root/test/compile-project" -e "$platform" \
|
||||
-l WiFiManager --no-save --skip-dependencies >/dev/null
|
||||
fi
|
||||
pio run -d "$root/test/compile-project" -e "$platform"
|
||||
pio_for_platform run -d "$root/test/compile-project" -e "$environment"
|
||||
|
||||
echo "WiFiManager consumer compile check passed for $platform"
|
||||
|
||||
@@ -20,7 +20,7 @@ lib_deps =
|
||||
|
||||
[env:esp32]
|
||||
extends = common
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
|
||||
board = esp32dev
|
||||
build_unflags = -std=gnu++11
|
||||
build_flags =
|
||||
|
||||
@@ -11,6 +11,42 @@ Use it through the repository runner so a secondary Wi-Fi adapter is explicitly
|
||||
--client-interface wlx...
|
||||
```
|
||||
|
||||
The ESP8266 portal SSID is `WM Contract ESP8266`; the ESP32 SSID is `WM Contract ESP32`. Both use `default1` exclusively for local development tests.
|
||||
The 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,
|
||||
|
@@ -6,6 +6,9 @@ framework = arduino
|
||||
lib_ldf_mode = deep+
|
||||
lib_deps =
|
||||
WiFiManager=symlink://../..
|
||||
build_flags =
|
||||
extra_scripts =
|
||||
pre:tools/ota_fixture_input.py
|
||||
|
||||
[env:esp8266]
|
||||
extends = common
|
||||
@@ -18,7 +21,7 @@ build_flags =
|
||||
|
||||
[env:esp32]
|
||||
extends = common
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
|
||||
board = esp32dev
|
||||
build_unflags = -std=gnu++11
|
||||
build_flags =
|
||||
@@ -26,3 +29,22 @@ build_flags =
|
||||
-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
|
||||
|
||||
@@ -3,31 +3,132 @@
|
||||
|
||||
namespace {
|
||||
|
||||
#if defined(ESP8266)
|
||||
constexpr char kPortalSsid[] = "WM Contract ESP8266";
|
||||
// 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
|
||||
constexpr char kPortalSsid[] = "WM Contract ESP32";
|
||||
#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", "Contract fixture", 32);
|
||||
"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(300);
|
||||
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);
|
||||
|
||||
// The fixture intentionally has no station credentials. A finite window
|
||||
// exercises timeout reset without leaving a board in a permanent portal.
|
||||
// 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));
|
||||
wifi.portalAddParameter(&kInstallationLabel);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
@@ -1,4 +1,5 @@
|
||||
# Ignored local credentials for the optional station handoff test.
|
||||
# These values are mounted read-only into the Docker test container.
|
||||
# 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
|
||||
|
||||
@@ -17,6 +17,7 @@ TestCase tests[] = {
|
||||
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
|
||||
@@ -39,7 +40,7 @@ TestCase tests[] = {
|
||||
TEST_ENTRY(test_get_config_portal_ssid),
|
||||
TEST_ENTRY(test_bootstrap_json_portal_feature_flags),
|
||||
TEST_ENTRY(test_portal_default_presentation),
|
||||
TEST_ENTRY(test_bootstrap_json_contract_v3),
|
||||
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),
|
||||
@@ -105,6 +106,7 @@ 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
|
||||
|
||||
@@ -39,7 +39,7 @@ void test_config_portal_already_active();
|
||||
void test_get_config_portal_ssid();
|
||||
void test_bootstrap_json_portal_feature_flags();
|
||||
void test_portal_default_presentation();
|
||||
void test_bootstrap_json_contract_v3();
|
||||
void test_bootstrap_json_schema_v3();
|
||||
void test_bootstrap_json_snapshot_consistency();
|
||||
void test_root_render_interleaved_context_isolation();
|
||||
void test_portal_presentation_configuration();
|
||||
@@ -107,6 +107,7 @@ 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
|
||||
@@ -122,6 +123,7 @@ 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
|
||||
|
||||
@@ -167,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...");
|
||||
|
||||
|
||||
@@ -87,8 +87,8 @@ void test_portal_default_presentation() {
|
||||
TEST_ASSERT_NOT_EQUAL(-1, bootstrap.indexOf(F("\"logoAltText\":\"\"")));
|
||||
}
|
||||
|
||||
void test_bootstrap_json_contract_v3() {
|
||||
Serial.println("[TEST] Testing bootstrap JSON v3 contract...");
|
||||
void test_bootstrap_json_schema_v3() {
|
||||
Serial.println("[TEST] Testing bootstrap JSON v3 schema...");
|
||||
|
||||
WiFiManager wm;
|
||||
WiFiManagerHandlers handlers(&wm);
|
||||
@@ -143,7 +143,7 @@ void test_bootstrap_json_contract_v3() {
|
||||
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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
ARG PLAYWRIGHT_VERSION=1.63.0
|
||||
FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble
|
||||
|
||||
ARG PLAYWRIGHT_VERSION
|
||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
WORKDIR /work
|
||||
COPY tests/portal-contract/package.json tests/portal-contract/package-lock.json ./
|
||||
RUN npm ci --ignore-scripts && \
|
||||
[ "$(node -p "require('@playwright/test/package.json').version")" = "$PLAYWRIGHT_VERSION" ]
|
||||
COPY tests/portal-contract/ ./
|
||||
CMD ["npm", "test"]
|
||||
@@ -1,11 +0,0 @@
|
||||
# Portal contract container
|
||||
|
||||
This directory contains the browser/API half of the real-hardware portal test.
|
||||
Run it through [`../../tools/portal-hardware`](../../tools/portal-hardware), not
|
||||
directly: the host command alone selects the serial board and attaches the
|
||||
explicit secondary Wi-Fi adapter. Docker uses the host network only to reach
|
||||
the already-routed `192.168.4.1` portal; it never manages host Wi-Fi.
|
||||
|
||||
The image pins the Playwright package to the matching official browser image.
|
||||
Artifacts, traces, screenshots, JSON results, and the HTML report are written
|
||||
to the output directory printed by the host command.
|
||||
@@ -1,18 +0,0 @@
|
||||
services:
|
||||
portal-contract:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: tests/portal-contract/Dockerfile
|
||||
args:
|
||||
PLAYWRIGHT_VERSION: "1.63.0"
|
||||
network_mode: host
|
||||
ipc: host
|
||||
init: true
|
||||
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
PORTAL_URL: http://192.168.4.1
|
||||
PORTAL_BROWSER_MODE: ${PORTAL_BROWSER_MODE:-auto}
|
||||
ARTIFACT_DIR: /artifacts
|
||||
volumes:
|
||||
- ${PORTAL_ARTIFACT_DIR:?portal artifact directory is required}:/artifacts
|
||||
@@ -1,111 +0,0 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
async function json(response) {
|
||||
return JSON.parse(await response.text());
|
||||
}
|
||||
|
||||
async function waitForScan(request) {
|
||||
let result;
|
||||
await expect.poll(async () => {
|
||||
try {
|
||||
const response = await request.get('/api/wifi/scan-status');
|
||||
if (!response.ok()) return true;
|
||||
result = await json(response);
|
||||
return result.scanning;
|
||||
} catch {
|
||||
// ESP8266 AP+STA scans briefly leave the AP channel. A client can lose
|
||||
// its association while the radio scans, then reconnect before the
|
||||
// asynchronous scan completes. Keep polling; the assertions below still
|
||||
// require a reachable portal with a complete, valid result.
|
||||
return true;
|
||||
}
|
||||
}, { timeout: 45_000, intervals: [500, 800, 1_000] }).toBe(false);
|
||||
return result;
|
||||
}
|
||||
|
||||
test.describe('portal AP contract', () => {
|
||||
test('serves API, persists fixture parameters, and completes a real scan', async ({ request }) => {
|
||||
const root = await request.get('/');
|
||||
expect(root.ok()).toBeTruthy();
|
||||
expect(await root.text()).toContain('<html');
|
||||
|
||||
const [bootstrapResponse, concurrentRoot] = await Promise.all([
|
||||
request.get('/api/bootstrap'),
|
||||
request.get('/'),
|
||||
]);
|
||||
expect(concurrentRoot.ok()).toBeTruthy();
|
||||
const bootstrap = await json(bootstrapResponse);
|
||||
expect(bootstrap.contractVersion).toBe(3);
|
||||
expect(bootstrap.context.portalActive).toBe(true);
|
||||
|
||||
const metaResponse = await request.get('/api/wifi/meta');
|
||||
expect(metaResponse.ok()).toBeTruthy();
|
||||
const meta = await json(metaResponse);
|
||||
expect(JSON.stringify(meta)).toContain('installation_label');
|
||||
|
||||
const infoResponse = await request.get('/api/info');
|
||||
expect(infoResponse.ok()).toBeTruthy();
|
||||
expect(JSON.stringify(await json(infoResponse))).toContain('192.168.4.1');
|
||||
|
||||
const statusResponse = await request.get('/api/status');
|
||||
expect(statusResponse.ok()).toBeTruthy();
|
||||
|
||||
const saveResponse = await request.post('/api/params/save', {
|
||||
form: { installation_label: 'Contract verified' },
|
||||
});
|
||||
expect(saveResponse.ok()).toBeTruthy();
|
||||
expect(JSON.stringify(await json(saveResponse))).toContain('saved');
|
||||
|
||||
const paramsResponse = await request.get('/api/params');
|
||||
expect(paramsResponse.ok()).toBeTruthy();
|
||||
expect(JSON.stringify(await json(paramsResponse))).toContain('Contract verified');
|
||||
|
||||
const resetResponse = await request.post('/api/portal/timeout-reset');
|
||||
expect(resetResponse.ok()).toBeTruthy();
|
||||
expect((await json(resetResponse)).timeoutSecondsRemaining).toBeGreaterThan(800);
|
||||
|
||||
const scanStart = await request.post('/api/wifi/scan');
|
||||
expect([200, 202, 409]).toContain(scanStart.status());
|
||||
const completed = await waitForScan(request);
|
||||
expect(completed.state).toBe('complete');
|
||||
expect(completed.results_valid).toBe(true);
|
||||
|
||||
const missing = await request.get('/not-a-portal-route');
|
||||
expect(missing.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('renders stable desktop and mobile portal views without page errors', async ({ browser }) => {
|
||||
test.skip(process.env.PORTAL_BROWSER_MODE === 'skip', 'Browser checks were explicitly skipped.');
|
||||
const desktop = await browser.newContext({ viewport: { width: 1440, height: 1080 } });
|
||||
const page = await desktop.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text());
|
||||
});
|
||||
|
||||
await page.goto('/', { waitUntil: 'networkidle' });
|
||||
await expect(page.locator('#wm-reset-portal-timeout')).toBeVisible();
|
||||
await page.screenshot({ path: `${process.env.ARTIFACT_DIR}/portal-overview-desktop.png`, fullPage: true });
|
||||
|
||||
await page.goto('/#/wifi', { waitUntil: 'networkidle' });
|
||||
await expect(page.locator('#wm-refresh-scan')).toBeVisible();
|
||||
await expect(page.locator('#wm-f-installation_label')).toBeVisible();
|
||||
await page.locator('#wm-f-installation_label').fill('Browser verified');
|
||||
await page.screenshot({ path: `${process.env.ARTIFACT_DIR}/portal-wifi-desktop.png`, fullPage: true });
|
||||
|
||||
const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true });
|
||||
const mobilePage = await mobile.newPage();
|
||||
mobilePage.on('pageerror', (error) => errors.push(error.message));
|
||||
mobilePage.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text());
|
||||
});
|
||||
await mobilePage.goto('/#/info', { waitUntil: 'networkidle' });
|
||||
await expect(mobilePage.locator('.wm-page-head')).toBeVisible();
|
||||
await mobilePage.screenshot({ path: `${process.env.ARTIFACT_DIR}/portal-device-mobile.png`, fullPage: true });
|
||||
|
||||
await mobile.close();
|
||||
await desktop.close();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
services:
|
||||
portal-contract:
|
||||
portal-harness:
|
||||
environment:
|
||||
PORTAL_STATION_ENV: /run/secrets/portal-station.env
|
||||
volumes:
|
||||
@@ -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
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "wifimanager-portal-contract",
|
||||
"name": "wifimanager-portal-harness",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wifimanager-portal-contract",
|
||||
"name": "wifimanager-portal-harness",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0"
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "wifimanager-portal-contract",
|
||||
"name": "wifimanager-portal-harness",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
+9
-3
@@ -1,7 +1,13 @@
|
||||
// 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',
|
||||
@@ -18,8 +24,8 @@ module.exports = defineConfig({
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.PORTAL_URL || 'http://192.168.4.1',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'retain-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
+1
@@ -1,3 +1,4 @@
|
||||
// Optional LAN handoff exercised by the portal test harness.
|
||||
const fs = require('fs');
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
@@ -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" "$@"
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared host-side helpers for the WiFiManager portal hardware contract.
|
||||
# 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() {
|
||||
@@ -13,21 +15,121 @@ wm_require() {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
local lock_file="${WM_HARDWARE_LOCK_FILE:-/tmp/wifimanager-hardware.lock}"
|
||||
exec 9>"$lock_file"
|
||||
flock -n 9 || {
|
||||
echo "Another WiFiManager hardware task is already running; wait for it to finish." >&2
|
||||
return 1
|
||||
}
|
||||
# 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 active_connection
|
||||
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
|
||||
@@ -37,7 +139,23 @@ wm_require_client_adapter() {
|
||||
echo "Refusing to use the host default-route interface: $interface" >&2
|
||||
return 1
|
||||
}
|
||||
active_connection="$(nmcli -g GENERAL.CONNECTION device show "$interface" 2>/dev/null || true)"
|
||||
# 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
|
||||
@@ -47,21 +165,31 @@ wm_require_client_adapter() {
|
||||
|
||||
wm_portal_ssid() {
|
||||
case "$1" in
|
||||
esp8266) printf '%s\n' 'WM Contract ESP8266' ;;
|
||||
esp32) printf '%s\n' 'WM Contract ESP32' ;;
|
||||
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
|
||||
nmcli device wifi rescan ifname "$interface" >/dev/null 2>&1 || true
|
||||
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 nmcli -t -f SSID device wifi list ifname "$interface" | grep -Fxq "$ssid"; then
|
||||
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
|
||||
nmcli device wifi rescan ifname "$interface" >/dev/null 2>&1 || true
|
||||
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
|
||||
@@ -70,36 +198,120 @@ wm_wait_for_portal_ssid() {
|
||||
wm_remove_connection_by_name() {
|
||||
local name="$1"
|
||||
[[ -n "$name" ]] || return 0
|
||||
nmcli connection down "$name" >/dev/null 2>&1 || true
|
||||
nmcli connection delete "$name" >/dev/null 2>&1 || true
|
||||
wm_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" name uuid
|
||||
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)"
|
||||
nmcli device disconnect "$interface" >/dev/null 2>&1 || true
|
||||
wm_wait_for_portal_ssid "$interface" "$ssid"
|
||||
if ! nmcli connection add type wifi ifname "$interface" con-name "$name" ssid "$ssid" \
|
||||
# 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
|
||||
if ! nmcli connection modify "$name" wifi-sec.key-mgmt wpa-psk wifi-sec.psk "$password"; then
|
||||
wm_remove_connection_by_name "$name"
|
||||
return 1
|
||||
fi
|
||||
if ! nmcli connection up "$name" ifname "$interface"; then
|
||||
wm_remove_connection_by_name "$name"
|
||||
return 1
|
||||
fi
|
||||
uuid="$(nmcli -g connection.uuid connection show "$name")"
|
||||
# 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_remove_connection_by_name "$name"
|
||||
wm_cleanup_created_connection
|
||||
echo "NetworkManager did not return a UUID for the portal connection." >&2
|
||||
return 1
|
||||
fi
|
||||
WM_PORTAL_CONNECTION_UUID="$uuid"
|
||||
WM_PORTAL_CONNECTION_NAME="$name"
|
||||
export WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
|
||||
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() {
|
||||
@@ -112,10 +324,38 @@ wm_verify_portal_route() {
|
||||
}
|
||||
|
||||
wm_remove_connection() {
|
||||
local uuid="$1"
|
||||
[[ -n "$uuid" ]] || return 0
|
||||
nmcli connection down uuid "$uuid" >/dev/null 2>&1 || true
|
||||
nmcli connection delete uuid "$uuid" >/dev/null 2>&1 || true
|
||||
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() {
|
||||
@@ -132,16 +372,25 @@ wm_require_no_active_session() {
|
||||
}
|
||||
|
||||
wm_write_state() {
|
||||
local interface="$1" platform="$2" uuid="$3" name="$4" root file
|
||||
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"
|
||||
(
|
||||
umask 077
|
||||
printf 'WM_PORTAL_INTERFACE=%s\nWM_PORTAL_PLATFORM=%s\nWM_PORTAL_CONNECTION_UUID=%s\nWM_PORTAL_CONNECTION_NAME=%s\n' \
|
||||
"$interface" "$platform" "$uuid" "$name" >"$file"
|
||||
)
|
||||
chmod 600 "$file"
|
||||
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() {
|
||||
@@ -155,9 +404,10 @@ wm_load_state() {
|
||||
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_INTERFACE|WM_PORTAL_PLATFORM|WM_PORTAL_CONNECTION_UUID|WM_PORTAL_CONNECTION_NAME)
|
||||
WM_PORTAL_STATE|WM_PORTAL_INTERFACE|WM_PORTAL_PLATFORM|WM_PORTAL_CONNECTION_UUID|WM_PORTAL_CONNECTION_NAME)
|
||||
printf -v "$key" '%s' "$value"
|
||||
;;
|
||||
'') ;;
|
||||
@@ -167,15 +417,38 @@ wm_load_state() {
|
||||
;;
|
||||
esac
|
||||
done <"$file"
|
||||
[[ -n "$WM_PORTAL_INTERFACE" && -n "$WM_PORTAL_PLATFORM" && -n "$WM_PORTAL_CONNECTION_UUID" && -n "$WM_PORTAL_CONNECTION_NAME" ]] || {
|
||||
[[ -n "$WM_PORTAL_INTERFACE" && -n "$WM_PORTAL_PLATFORM" && -n "$WM_PORTAL_CONNECTION_NAME" ]] || {
|
||||
echo "Incomplete WiFiManager portal session state." >&2
|
||||
return 1
|
||||
}
|
||||
export WM_PORTAL_INTERFACE WM_PORTAL_PLATFORM WM_PORTAL_CONNECTION_UUID WM_PORTAL_CONNECTION_NAME
|
||||
# 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"
|
||||
rm -f -- "$file"
|
||||
}
|
||||
|
||||
+762
-29
@@ -2,8 +2,17 @@
|
||||
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
|
||||
@@ -13,12 +22,22 @@ Usage:
|
||||
--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]
|
||||
[--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` cleanup.
|
||||
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
|
||||
}
|
||||
@@ -35,6 +54,24 @@ 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 ;;
|
||||
@@ -45,6 +82,8 @@ while [[ $# -gt 0 ]]; do
|
||||
--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
|
||||
@@ -52,19 +91,63 @@ done
|
||||
require_common() {
|
||||
wm_require ip
|
||||
wm_require nmcli
|
||||
wm_require pio
|
||||
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
|
||||
output_dir="$(wm_portal_state_root)/runs/$(date -u +%Y%m%dT%H%M%SZ)-$platform"
|
||||
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
|
||||
@@ -77,45 +160,623 @@ validate_run_arguments() {
|
||||
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 ssid
|
||||
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")"
|
||||
|
||||
pio run -d "$root/test/portal-harness" -e "$platform" -t upload --upload-port "$port"
|
||||
if ! wm_create_portal_connection "$client_interface" "$ssid" "default1"; then
|
||||
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_remove_connection "$WM_PORTAL_CONNECTION_UUID"
|
||||
wm_cleanup_created_connection
|
||||
return 1
|
||||
fi
|
||||
if ! wm_write_state "$client_interface" "$platform" "$WM_PORTAL_CONNECTION_UUID" "$WM_PORTAL_CONNECTION_NAME"; then
|
||||
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
|
||||
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"
|
||||
}
|
||||
|
||||
finish_portal_session() {
|
||||
wm_load_state
|
||||
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
|
||||
wm_clear_state
|
||||
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)
|
||||
@@ -124,31 +785,103 @@ case "$command_name" in
|
||||
down)
|
||||
[[ -z "$platform$port$client_interface$station_env$output_dir" ]] || usage
|
||||
wm_acquire_hardware_lock
|
||||
finish_portal_session
|
||||
if ! finish_portal_session; then
|
||||
trap - EXIT HUP INT TERM
|
||||
exit 1
|
||||
fi
|
||||
echo 'Portal client connection removed.'
|
||||
;;
|
||||
run)
|
||||
start_portal_session
|
||||
cleanup() {
|
||||
if [[ "$keep" != "yes" ]]; then
|
||||
finish_portal_session || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
export PORTAL_ARTIFACT_DIR="$output_dir"
|
||||
export LOCAL_UID="$(id -u)"
|
||||
export LOCAL_GID="$(id -g)"
|
||||
export PORTAL_BROWSER_MODE="$browser"
|
||||
compose_files=(-f "$root/tests/portal-contract/compose.yaml")
|
||||
if [[ -n "$station_env" ]]; then
|
||||
export PORTAL_STATION_ENV_HOST="$(cd "$(dirname "$station_env")" && pwd)/$(basename "$station_env")"
|
||||
compose_files+=(-f "$root/tests/portal-contract/compose.station.yaml")
|
||||
# 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
|
||||
# The contract source is copied into the image; rebuild with Docker cache so
|
||||
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-contract
|
||||
docker compose "${compose_files[@]}" run --rm portal-contract
|
||||
printf 'Portal contract passed. Artifacts: %s\n' "$output_dir"
|
||||
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
|
||||
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source_dir=""
|
||||
replace="no"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF' >&2
|
||||
Usage: ./tools/promote-readme-media --from ARTIFACT_DIRECTORY --replace
|
||||
|
||||
Copies approved WiFiManager README media from a successful ESP32 capture. The
|
||||
source directory is the run directory containing readme-media/manifest.json.
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--from) [[ $# -ge 2 ]] || usage; source_dir="$2"; shift 2 ;;
|
||||
--replace) replace="yes"; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$source_dir" && "$replace" == "yes" ]] || usage
|
||||
source_dir="$(cd "$source_dir" && pwd)"
|
||||
media_dir="$source_dir/readme-media"
|
||||
manifest="$media_dir/manifest.json"
|
||||
|
||||
[[ -r "$manifest" ]] || { echo "Missing README media manifest: $manifest" >&2; exit 1; }
|
||||
grep -Fq '"project":"WiFiManager"' "$manifest" &&
|
||||
grep -Fq '"kind":"readme-media"' "$manifest" &&
|
||||
grep -Fq '"status":"passed"' "$manifest" &&
|
||||
grep -Fq '"platform":"esp32"' "$manifest" || {
|
||||
echo "The media manifest is not a successful WiFiManager ESP32 capture." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
target_dir="$root/docs/assets/readme"
|
||||
validate_asset() {
|
||||
local source_file="$1" expected_type="$2" max_bytes="$3"
|
||||
[[ -s "$source_file" ]] || { echo "Missing media asset: $source_file" >&2; exit 1; }
|
||||
[[ "$(file --brief --mime-type "$source_file")" == "$expected_type" ]] || {
|
||||
echo "Unexpected media type for $source_file" >&2
|
||||
exit 1
|
||||
}
|
||||
(( $(wc -c < "$source_file") <= max_bytes )) || {
|
||||
echo "README media exceeds its size limit: $source_file" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Verify the complete candidate before changing any tracked README asset.
|
||||
validate_asset "$media_dir/portal-tour.gif" image/gif $((2 * 1024 * 1024))
|
||||
validate_asset "$media_dir/portal-overview.png" image/png $((1024 * 1024))
|
||||
validate_asset "$media_dir/portal-wifi-settings.png" image/png $((1024 * 1024))
|
||||
|
||||
install -d "$target_dir"
|
||||
for asset in portal-tour.gif portal-overview.png portal-wifi-settings.png; do
|
||||
source_file="$media_dir/$asset"
|
||||
cp -- "$source_file" "$target_dir/$asset"
|
||||
printf 'Promoted %s\n' "docs/assets/readme/$asset"
|
||||
done
|
||||
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit-check the passive portal-OTA serial recorder without real hardware."""
|
||||
|
||||
import importlib.util
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FakeSerial:
|
||||
events = []
|
||||
read_calls = 0
|
||||
|
||||
def __init__(self, port=None, **kwargs):
|
||||
assert port is None
|
||||
assert kwargs["baudrate"] == 115200
|
||||
assert kwargs["timeout"] == 0.25
|
||||
assert kwargs["rtscts"] is False
|
||||
assert kwargs["dsrdtr"] is False
|
||||
self._dtr = True
|
||||
self._rts = True
|
||||
self._port = None
|
||||
self.is_open = False
|
||||
self.events.append(("init", port))
|
||||
|
||||
@property
|
||||
def dtr(self):
|
||||
return self._dtr
|
||||
|
||||
@dtr.setter
|
||||
def dtr(self, value):
|
||||
self._dtr = value
|
||||
self.events.append(("dtr", value))
|
||||
|
||||
@property
|
||||
def rts(self):
|
||||
return self._rts
|
||||
|
||||
@rts.setter
|
||||
def rts(self, value):
|
||||
self._rts = value
|
||||
self.events.append(("rts", value))
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
return self._port
|
||||
|
||||
@port.setter
|
||||
def port(self, value):
|
||||
self._port = value
|
||||
self.events.append(("port", value))
|
||||
|
||||
def open(self):
|
||||
assert self._dtr is False
|
||||
assert self._rts is False
|
||||
self.is_open = True
|
||||
self.events.append(("open", self._dtr, self._rts, self._port))
|
||||
|
||||
def close(self):
|
||||
self.is_open = False
|
||||
self.events.append(("close",))
|
||||
|
||||
def read(self, _size):
|
||||
type(self).read_calls += 1
|
||||
return b"[OTA] serial evidence\n" if type(self).read_calls == 1 else b""
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, _type, _value, _traceback):
|
||||
self.close()
|
||||
|
||||
|
||||
class FakeClock:
|
||||
def __init__(self):
|
||||
self.value = 0.0
|
||||
|
||||
def __call__(self):
|
||||
return self.value
|
||||
|
||||
def advance(self, seconds):
|
||||
self.value += seconds
|
||||
|
||||
|
||||
class BlockingEmptySerial(FakeSerial):
|
||||
clock = None
|
||||
|
||||
def read(self, _size):
|
||||
type(self).read_calls += 1
|
||||
type(self).clock.advance(0.25)
|
||||
return b""
|
||||
|
||||
|
||||
def load_capture_module():
|
||||
fake_serial_module = types.ModuleType("serial")
|
||||
fake_serial_module.Serial = FakeSerial
|
||||
fake_serial_module.SerialException = OSError
|
||||
sys.modules["serial"] = fake_serial_module
|
||||
|
||||
source = Path(__file__).resolve().parents[1] / "capture-serial.py"
|
||||
spec = importlib.util.spec_from_file_location("capture_serial", source)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def main():
|
||||
module = load_capture_module()
|
||||
|
||||
serial_port = module.open_capture_port("/dev/fake")
|
||||
assert FakeSerial.events == [
|
||||
("init", None),
|
||||
("dtr", False),
|
||||
("rts", False),
|
||||
("port", "/dev/fake"),
|
||||
("open", False, False, "/dev/fake"),
|
||||
]
|
||||
serial_port.close()
|
||||
|
||||
FakeSerial.events.clear()
|
||||
FakeSerial.read_calls = 0
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
output = Path(temporary_directory) / "serial-ota.log"
|
||||
ready = Path(temporary_directory) / "serial-ota.ready"
|
||||
original_write_ready = module.write_ready
|
||||
|
||||
def checked_write_ready(path):
|
||||
assert ("open", False, False, "/dev/fake") in FakeSerial.events
|
||||
original_write_ready(path)
|
||||
|
||||
module.write_ready = checked_write_ready
|
||||
assert module.capture(
|
||||
"/dev/fake",
|
||||
output,
|
||||
ready,
|
||||
should_stop=lambda: FakeSerial.read_calls >= 2,
|
||||
) == 0
|
||||
assert ready.read_text() == "ready\n"
|
||||
assert output.read_bytes() == b"[OTA] serial evidence\n"
|
||||
assert stat.S_IMODE(output.stat().st_mode) == 0o600
|
||||
assert stat.S_IMODE(ready.stat().st_mode) == 0o600
|
||||
|
||||
assert ("open", False, False, "/dev/fake") in FakeSerial.events
|
||||
assert ("close",) in FakeSerial.events
|
||||
assert FakeSerial.read_calls == 2
|
||||
|
||||
# An immediate empty-return backend must back off progressively instead of
|
||||
# spinning. This takes no real sleep because the sleeper is injected.
|
||||
FakeSerial.read_calls = 0
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
output = Path(temporary_directory) / "serial-ota.log"
|
||||
ready = Path(temporary_directory) / "serial-ota.ready"
|
||||
empty_sleeps = []
|
||||
assert module.capture(
|
||||
"/dev/fake",
|
||||
output,
|
||||
ready,
|
||||
should_stop=lambda: FakeSerial.read_calls >= 5,
|
||||
sleep=empty_sleeps.append,
|
||||
) == 0
|
||||
assert empty_sleeps == [0.01, 0.02, 0.04, 0.08]
|
||||
|
||||
# A normal 250 ms serial timeout is already rate-limited by the device
|
||||
# driver, so it must not receive an additional backoff sleep.
|
||||
module.serial.Serial = BlockingEmptySerial
|
||||
BlockingEmptySerial.events = []
|
||||
BlockingEmptySerial.read_calls = 0
|
||||
clock = FakeClock()
|
||||
BlockingEmptySerial.clock = clock
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
output = Path(temporary_directory) / "serial-ota.log"
|
||||
ready = Path(temporary_directory) / "serial-ota.ready"
|
||||
empty_sleeps = []
|
||||
assert module.capture(
|
||||
"/dev/fake",
|
||||
output,
|
||||
ready,
|
||||
should_stop=lambda: BlockingEmptySerial.read_calls >= 2,
|
||||
clock=clock,
|
||||
sleep=empty_sleeps.append,
|
||||
) == 0
|
||||
assert empty_sleeps == []
|
||||
module.serial.Serial = FakeSerial
|
||||
|
||||
FakeSerial.read_calls = 0
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
output = Path(temporary_directory) / "serial-ota.log"
|
||||
ready = Path(temporary_directory) / "serial-ota.ready"
|
||||
assert module.capture(
|
||||
"/dev/fake",
|
||||
output,
|
||||
ready,
|
||||
should_stop=lambda: False,
|
||||
deadline_seconds=0.0,
|
||||
) == 2
|
||||
|
||||
print("WiFiManager passive OTA serial-capture test-harness check passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# Check OTA fixture identity and environment locking without PlatformIO or a board.
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/wifimanager-ota-identity-test.XXXXXX")"
|
||||
chmod 700 "$temporary_root"
|
||||
cleanup() {
|
||||
rm -rf -- "$temporary_root"
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
export ARDUINO_TEST_HARNESS_LOCK_DIR="$temporary_root/locks"
|
||||
export WIFIMANAGER_OTA_IDENTITY_DIR="$temporary_root/identity"
|
||||
# shellcheck source=tools/lib/ota-fixture-identity.sh
|
||||
source "$root/tools/lib/ota-fixture-identity.sh"
|
||||
# shellcheck source=tools/lib/harness-locks.sh
|
||||
source "$root/tools/lib/harness-locks.sh"
|
||||
|
||||
wm_write_ota_fixture_identity A
|
||||
identity_file="$WIFIMANAGER_OTA_IDENTITY_DIR/ota_fixture_identity.h"
|
||||
[[ "$(stat -c '%a' "$WIFIMANAGER_OTA_IDENTITY_DIR")" == 700 ]]
|
||||
[[ "$(stat -c '%a' "$identity_file")" == 600 ]]
|
||||
grep -Fqx '#define WM_OTA_FIXTURE_IMAGE "A"' "$identity_file"
|
||||
|
||||
wm_lock_ota_fixture_environment esp8266_ota
|
||||
collision_output="$temporary_root/lock-collision.log"
|
||||
if (
|
||||
root="$root"
|
||||
export WIFIMANAGER_OTA_IDENTITY_DIR="$temporary_root/other-identity"
|
||||
# shellcheck source=tools/lib/ota-fixture-identity.sh
|
||||
source "$root/tools/lib/ota-fixture-identity.sh"
|
||||
wm_lock_ota_fixture_environment esp8266_ota
|
||||
) 2>"$collision_output"; then
|
||||
echo "A second test-harness process unexpectedly acquired the same OTA environment lock." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fqx "OTA fixture environment 'esp8266_ota' is already in use by another local test-harness run." "$collision_output"
|
||||
|
||||
wm_harness_lock_resource "test resource" "identity-test:shared-resource"
|
||||
if (
|
||||
# shellcheck source=tools/lib/harness-locks.sh
|
||||
source "$root/tools/lib/harness-locks.sh"
|
||||
wm_harness_lock_resource "test resource" "identity-test:shared-resource"
|
||||
) 2>"$collision_output"; then
|
||||
echo "A second test-harness process unexpectedly acquired the same resource lock." >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fqx 'Cannot start: test resource is already in use by another local test-harness process.' "$collision_output"
|
||||
|
||||
wm_remove_ota_fixture_identity
|
||||
[[ ! -e "$identity_file" ]]
|
||||
echo "WiFiManager OTA fixture identity test-harness check passed"
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
tmp="$(mktemp -d "${TMPDIR:-/tmp}/wifimanager-portal-cli.XXXXXX")"
|
||||
cleanup() { rm -rf "$tmp"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
stub_bin="$tmp/bin"
|
||||
mkdir -p "$stub_bin"
|
||||
export CALL_LOG="$tmp/calls.log"
|
||||
export WM_HARDWARE_LOCK_FILE="$tmp/hardware.lock"
|
||||
export XDG_STATE_HOME="$tmp/state"
|
||||
|
||||
printf '%s\n' '#!/usr/bin/env bash' \
|
||||
'if [[ "$1" == "link" && "$2" == "show" ]]; then exit 0; fi' \
|
||||
'if [[ "$1" == "route" && "$2" == "show" ]]; then echo "default via 192.0.2.1 dev wlan-main"; exit 0; fi' \
|
||||
'echo "192.168.4.1 dev wlan-client src 192.168.4.2"' >"$stub_bin/ip"
|
||||
printf '%s\n' '#!/usr/bin/env bash' \
|
||||
'printf "%s\\n" "$*" >>"$CALL_LOG"' \
|
||||
'if [[ "${NMCLI_FAIL_UP:-}" == "yes" && "$1" == "connection" && "$2" == "up" ]]; then exit 7; fi' \
|
||||
'if [[ "$1" == "-t" && "$2" == "-f" && "$3" == "SSID" ]]; then echo "WM Contract ESP8266"; exit 0; fi' \
|
||||
'if [[ "$1" == "-g" && "$2" == "connection.uuid" ]]; then echo "stub-uuid"; exit 0; fi' \
|
||||
'if [[ "$1" == "-g" ]]; then echo "--"; fi' >"$stub_bin/nmcli"
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'exit 0' >"$stub_bin/pio"
|
||||
printf '%s\n' '#!/usr/bin/env bash' \
|
||||
'printf "docker %s\n" "$*" >>"$CALL_LOG"' \
|
||||
'if [[ "$1" == "compose" && "$2" == "version" ]]; then echo "Docker Compose"; exit 0; fi' \
|
||||
'exit 0' >"$stub_bin/docker"
|
||||
chmod 755 "$stub_bin"/*
|
||||
export PATH="$stub_bin:$PATH"
|
||||
|
||||
"$root/tools/portal-hardware" doctor --client-interface wlan-client >/dev/null
|
||||
if grep -Eq 'connection (add|modify|delete)|device disconnect' "$CALL_LOG"; then
|
||||
echo 'doctor unexpectedly changed a NetworkManager connection' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if "$root/tools/portal-hardware" doctor --client-interface wlan-main >/dev/null 2>&1; then
|
||||
echo 'default-route adapter guard did not reject the request' >&2
|
||||
exit 1
|
||||
fi
|
||||
if "$root/tools/portal-hardware" up --platform >/dev/null 2>&1; then
|
||||
echo 'missing option value did not reject the request' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A failed association must delete the only connection it just created.
|
||||
source "$root/tools/lib/portal-hardware-session.sh"
|
||||
wm_wait_for_portal_ssid() { return 0; }
|
||||
export NMCLI_FAIL_UP=yes
|
||||
if wm_create_portal_connection wlan-client 'fixture portal' placeholder; then
|
||||
echo 'failed association was reported as success' >&2
|
||||
exit 1
|
||||
fi
|
||||
unset NMCLI_FAIL_UP
|
||||
grep -Eq 'connection delete wifimanager-portal-' "$CALL_LOG"
|
||||
|
||||
# A retained session must be removed explicitly, never silently overwritten.
|
||||
wm_write_state wlan-client esp8266 stale-uuid stale-name
|
||||
wm_load_state
|
||||
[[ "$WM_PORTAL_INTERFACE" == "wlan-client" && "$WM_PORTAL_PLATFORM" == "esp8266" ]]
|
||||
[[ "$WM_PORTAL_CONNECTION_UUID" == "stale-uuid" && "$WM_PORTAL_CONNECTION_NAME" == "stale-name" ]]
|
||||
mutations_before="$(grep -Ec '^(device disconnect|connection (add|modify|delete|down))' "$CALL_LOG" || true)"
|
||||
if "$root/tools/portal-hardware" up --platform esp8266 --port /dev/null \
|
||||
--client-interface wlan-client --take-over-client-adapter >/dev/null 2>&1; then
|
||||
echo 'stale portal session was silently overwritten' >&2
|
||||
exit 1
|
||||
fi
|
||||
mutations_after="$(grep -Ec '^(device disconnect|connection (add|modify|delete|down))' "$CALL_LOG" || true)"
|
||||
[[ "$mutations_before" == "$mutations_after" ]] || {
|
||||
echo 'stale portal session mutated the selected adapter' >&2
|
||||
exit 1
|
||||
}
|
||||
wm_clear_state
|
||||
|
||||
# The runner must build the copied contract source before it starts the container.
|
||||
"$root/tools/portal-hardware" run --platform esp8266 --port /dev/null --client-interface wlan-client --browser skip >/dev/null
|
||||
build_line="$(grep -n " build portal-contract$" "$CALL_LOG" | tail -1 | cut -d: -f1)"
|
||||
run_line="$(grep -n " run --rm portal-contract$" "$CALL_LOG" | tail -1 | cut -d: -f1)"
|
||||
[[ -n "$build_line" && -n "$run_line" && "$build_line" -lt "$run_line" ]] || {
|
||||
echo "portal contract was not rebuilt before execution" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo 'portal-hardware CLI safety checks passed'
|
||||
Reference in New Issue
Block a user