mirror of
https://github.com/alexhopeoconnor/WiFiManager.git
synced 2026-10-04 02:48:13 +10:00
fix: harden portal OTA release
This commit is contained in:
@@ -21,11 +21,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- run: bash -n scripts/*.sh tools/check-ota-partitions.sh tools/portal-hardware tools/lib/*.sh tools/tests/*.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: |
|
- run: |
|
||||||
for spec in tests/portal-harness/tests/*.js; do
|
for spec in tests/portal-harness/tests/*.js; do
|
||||||
node --check "$spec"
|
node --check "$spec"
|
||||||
done
|
done
|
||||||
- run: timeout 15s python3 tools/tests/test-capture-serial.py
|
- 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: ./tools/check-ota-partitions.sh
|
||||||
- run: ./scripts/check-docs.sh
|
- run: ./scripts/check-docs.sh
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
SYS callback. The updater now enters asynchronous mode before its first
|
SYS callback. The updater now enters asynchronous mode before its first
|
||||||
erase or write, so the real browser upload can complete and restart into
|
erase or write, so the real browser upload can complete and restart into
|
||||||
the new firmware image.
|
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
|
## 3.2.4
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ The [Branded Portal](examples/BrandedPortal/) example includes a static SVG, acc
|
|||||||
```ini
|
```ini
|
||||||
[common]
|
[common]
|
||||||
lib_deps =
|
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.
|
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.
|
||||||
|
|||||||
+10
-8
@@ -33,17 +33,19 @@ the shared [ESP8266 linker-workaround note](https://github.com/alexhopeoconnor/a
|
|||||||
For the pioarduino release-to-Core mapping and cache-collision diagnosis, see
|
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).
|
[DeviceFramework's toolchain guide](https://github.com/alexhopeoconnor/DeviceFramework/blob/main/docs/TOOLCHAINS.md).
|
||||||
|
|
||||||
`./scripts/test.sh` and `./tools/portal-hardware ota --platform esp32` place
|
`./scripts/test.sh` and `./tools/portal-hardware ota --platform esp32` use the
|
||||||
the ESP32 A/B fixture,
|
PlatformIO Core/cache shared by the maintained framework repositories,
|
||||||
in a dedicated PlatformIO Core/cache directory, defaulting to
|
defaulting to `${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11`.
|
||||||
`${XDG_CACHE_HOME:-$HOME/.cache}/wifimanager-platformio/core-3.3.11`. That
|
WiFiManager, DeviceFramework, DFTE, and ArduinoHA pin this same graph, so this
|
||||||
keeps pioarduino's package-form `esptool` and generated environment separate
|
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
|
from stale global `tool-esptoolpy` metadata. Override the location with
|
||||||
`WIFIMANAGER_PLATFORMIO_CORE_DIR`,
|
`WIFIMANAGER_PLATFORMIO_CORE_DIR`,
|
||||||
`WIFIMANAGER_PLATFORMIO_PACKAGES_DIR`, and
|
`WIFIMANAGER_PLATFORMIO_PACKAGES_DIR`, and
|
||||||
`WIFIMANAGER_PLATFORMIO_CACHE_DIR` when space belongs elsewhere. The first
|
`WIFIMANAGER_PLATFORMIO_CACHE_DIR` when space belongs elsewhere or an isolated
|
||||||
first install is several GiB; reserve at least 4 GiB plus cache headroom. It is
|
diagnosis is needed. The first shared install is several GiB; reserve at least
|
||||||
persistent and is never cleared by normal test commands.
|
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
|
For a disposable cache investigation, point that variable at an exact temporary
|
||||||
directory, run the affected command, inspect the resolved graph, then remove
|
directory, run the affected command, inspect the resolved graph, then remove
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ void loop() {
|
|||||||
|
|
||||||
```ini
|
```ini
|
||||||
lib_deps =
|
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.
|
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.
|
||||||
|
|||||||
+19
-7
@@ -7,7 +7,7 @@ local Wi-Fi credentials, browser binary, or sibling checkout.
|
|||||||
| Physical test harness | Transport | Host adapter | Secret source | Required proof |
|
| 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 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 | serial A → updater accepts/completes B → serial B, plus browser automatic-reboot/B-twice proof |
|
| Portal HTTP OTA | WiFiManager multipart `POST /u` | Named secondary adapter | safe fixture AP password | rendered upload succeeds, portal restarts automatically, and fixture marker changes A → B twice |
|
||||||
|
|
||||||
The selected secondary adapter is intentionally never used for normal LAN
|
The selected secondary adapter is intentionally never used for normal LAN
|
||||||
testing. It is `never-default`, so the host's ordinary route remains intact.
|
testing. It is `never-default`, so the host's ordinary route remains intact.
|
||||||
@@ -40,6 +40,16 @@ 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
|
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.
|
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
|
## Local hardware lifecycle tests
|
||||||
|
|
||||||
The Unity suite runs portal-only firmware with no Wi-Fi credentials, MQTT,
|
The Unity suite runs portal-only firmware with no Wi-Fi credentials, MQTT,
|
||||||
@@ -242,8 +252,10 @@ HTTP route authentication.
|
|||||||
|
|
||||||
The test harness performs the following complete run:
|
The test harness performs the following complete run:
|
||||||
|
|
||||||
1. Builds immutable A and B fixture images. Their marker is compiled into the
|
1. Builds immutable A and B fixture images from one platform environment. A
|
||||||
binary, not saved in WiFiManager settings or EEPROM.
|
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;
|
2. Checks both ESP32 images against the explicit matching `app0`/`app1` slots;
|
||||||
ESP8266 validates B after A has booted against the exact aligned capacity
|
ESP8266 validates B after A has booted against the exact aligned capacity
|
||||||
passed to `Update.begin()`.
|
passed to `Update.begin()`.
|
||||||
@@ -261,10 +273,10 @@ The OTA command additionally requires Python with PySerial (the
|
|||||||
no-reset `serial-ota.log` beside the browser artifacts. It attaches immediately
|
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
|
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
|
marker check—and remains attached through the two B checks. A passing run
|
||||||
requires the log's ordered immutable A marker, WiFiManager's update-start and
|
requires a healthy recorder, but its contents are diagnostic evidence rather
|
||||||
update-complete lines, then immutable B marker. This preserves firmware-side
|
than a pass/fail comparison against product log strings. This preserves
|
||||||
portal-start and DHCP evidence as well as OTA evidence, without manufacturing a
|
firmware-side portal-start and DHCP evidence without manufacturing a reset.
|
||||||
reset. OTA-only fixture images wait five seconds after their upload reset so
|
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
|
the passive recorder can attach before A/B boot evidence is emitted; ordinary
|
||||||
portal test-harness startup remains fast.
|
portal test-harness startup remains fast.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ tag="${1:-}"
|
|||||||
[[ "${2:-}" == "" || "${2:-}" == "--tag" ]] || usage
|
[[ "${2:-}" == "" || "${2:-}" == "--tag" ]] || usage
|
||||||
|
|
||||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
# shellcheck source=tools/lib/platformio.sh
|
||||||
|
source "$root/tools/lib/platformio.sh"
|
||||||
version="${tag#v}"
|
version="${tag#v}"
|
||||||
manifest_version="$(sed -n 's/.*"version": "\([^"]*\)".*/\1/p' "$root/library.json" | head -n 1)"
|
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
|
git -C "$root" diff --check
|
||||||
package_dir="$(mktemp -d)"
|
package_dir="$(mktemp -d)"
|
||||||
trap 'rm -rf "$package_dir"' EXIT
|
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"
|
echo "Validated release metadata and PlatformIO package for $tag"
|
||||||
|
|
||||||
if [[ "${2:-}" == "--tag" ]]; then
|
if [[ "${2:-}" == "--tag" ]]; then
|
||||||
|
|||||||
+60
-20
@@ -1,6 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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() {
|
usage() {
|
||||||
cat <<'USAGE' >&2
|
cat <<'USAGE' >&2
|
||||||
Usage:
|
Usage:
|
||||||
@@ -35,30 +39,32 @@ esac
|
|||||||
[[ "$mode" != "hardware" || -e "$port" ]] || { echo "Serial port not found: $port" >&2; exit 1; }
|
[[ "$mode" != "hardware" || -e "$port" ]] || { echo "Serial port not found: $port" >&2; exit 1; }
|
||||||
|
|
||||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
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() {
|
pio_for_platform() {
|
||||||
if [[ "$platform" != "esp32" ]]; then
|
if [[ "$platform" != "esp32" ]]; then
|
||||||
pio "$@"
|
wm_pio "$@"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Keep the maintained Core 3.3.11 package form in a persistent project
|
# Keep the maintained Core 3.3.11 package form in a persistent project
|
||||||
# cache. It is never cleared by this script and avoids stale global
|
# cache shared by the maintained framework repositories. It is never
|
||||||
# package metadata selecting an incompatible uploader.
|
# 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
|
local core_dir packages_dir cache_dir
|
||||||
core_dir="${WIFIMANAGER_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/wifimanager-platformio/core-3.3.11}"
|
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}"
|
packages_dir="${WIFIMANAGER_PLATFORMIO_PACKAGES_DIR:-$core_dir/packages}"
|
||||||
cache_dir="${WIFIMANAGER_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
|
cache_dir="${WIFIMANAGER_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
|
||||||
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
|
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
|
||||||
PLATFORMIO_CORE_DIR="$core_dir" PLATFORMIO_PACKAGES_DIR="$packages_dir" \
|
PLATFORMIO_CORE_DIR="$core_dir" PLATFORMIO_PACKAGES_DIR="$packages_dir" \
|
||||||
PLATFORMIO_CACHE_DIR="$cache_dir" pio "$@"
|
PLATFORMIO_CACHE_DIR="$cache_dir" wm_pio "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_ota_fixture_pair() {
|
assert_ota_fixture_pair() {
|
||||||
local fixture_platform="$1"
|
local firmware_a="$1" firmware_b="$2" firmware size capacity
|
||||||
local firmware_a firmware_b firmware size capacity
|
|
||||||
firmware_a="$root/test/portal-harness/.pio/build/${fixture_platform}_ota_a/firmware.bin"
|
|
||||||
firmware_b="$root/test/portal-harness/.pio/build/${fixture_platform}_ota_b/firmware.bin"
|
|
||||||
[[ -s "$firmware_a" && -s "$firmware_b" ]] || {
|
[[ -s "$firmware_a" && -s "$firmware_b" ]] || {
|
||||||
echo "Portal OTA fixture build did not produce both A and B images." >&2
|
echo "Portal OTA fixture build did not produce both A and B images." >&2
|
||||||
return 1
|
return 1
|
||||||
@@ -72,7 +78,7 @@ assert_ota_fixture_pair() {
|
|||||||
for firmware in "$firmware_a" "$firmware_b"; do
|
for firmware in "$firmware_a" "$firmware_b"; do
|
||||||
size="$(wc -c < "$firmware" | tr -d '[:space:]')"
|
size="$(wc -c < "$firmware" | tr -d '[:space:]')"
|
||||||
(( size <= capacity )) || {
|
(( size <= capacity )) || {
|
||||||
echo "ESP32 OTA fixture $(basename "$(dirname "$firmware")") is $size bytes; it exceeds the $capacity-byte app slot." >&2
|
echo "ESP32 OTA fixture $(basename "$firmware") is $size bytes; it exceeds the $capacity-byte app slot." >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
done
|
done
|
||||||
@@ -80,10 +86,11 @@ assert_ota_fixture_pair() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if [[ "$mode" == "hardware" ]]; then
|
if [[ "$mode" == "hardware" ]]; then
|
||||||
# Keep serial flashing and portal-adapter work mutually exclusive.
|
# Unity uses only the named serial device; it does not own a portal AP or
|
||||||
# shellcheck source=tools/lib/portal-hardware-session.sh
|
# secondary adapter, so it may run beside an unrelated station test.
|
||||||
source "$root/tools/lib/portal-hardware-session.sh"
|
# shellcheck source=tools/lib/harness-locks.sh
|
||||||
wm_acquire_hardware_lock
|
source "$root/tools/lib/harness-locks.sh"
|
||||||
|
wm_harness_lock_serial_port "$port"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$mode" == "examples" ]]; then
|
if [[ "$mode" == "examples" ]]; then
|
||||||
@@ -100,14 +107,47 @@ if [[ "$mode" == "examples" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$mode" == "ota-fixtures" ]]; then
|
if [[ "$mode" == "ota-fixtures" ]]; then
|
||||||
fixture_platform="$platform"
|
fixture_environment="${platform}_ota"
|
||||||
if [[ "$fixture_platform" == "esp32" ]]; then
|
if [[ "$platform" == "esp32" ]]; then
|
||||||
"$root/tools/check-ota-partitions.sh"
|
"$root/tools/check-ota-partitions.sh"
|
||||||
fi
|
fi
|
||||||
for image in a b; do
|
|
||||||
pio_for_platform run -d "$root/test/portal-harness" -e "${fixture_platform}_ota_${image}" </dev/null
|
# The A/B marker is the only changed source input. Capture each resulting
|
||||||
done
|
# binary before rebuilding the same platform environment so CI proves the
|
||||||
assert_ota_fixture_pair "$fixture_platform"
|
# 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"
|
echo "WiFiManager portal OTA fixture compile check passed for $platform"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ the `WM_NMCLI_AUTH` options.
|
|||||||
|
|
||||||
## A/B portal OTA fixture
|
## A/B portal OTA fixture
|
||||||
|
|
||||||
The same fixture has dedicated `*_ota_a` and `*_ota_b` PlatformIO environments
|
The physical portal HTTP OTA test harness uses one `*_ota` environment per
|
||||||
for the physical portal HTTP OTA test harness. A and B differ only by a compiled
|
platform. It writes a harness-only A/B identity header to an ignored, owner-only
|
||||||
marker served from the fixture-only `/api/test/firmware-marker` endpoint and an
|
directory unique to that run before each build, so PlatformIO reuses dependency
|
||||||
immutable serial boot marker. The test harness requires serial A, updater
|
objects while the fixture-only `/api/test/firmware-marker` endpoint still proves
|
||||||
start/completion, then serial B, so it proves a B boot without trusting saved
|
the newly booted image. The test harness requires the real form's successful response, its automatic
|
||||||
portal values, EEPROM, or a filename.
|
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
|
```bash
|
||||||
./tools/portal-hardware ota \
|
./tools/portal-hardware ota \
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ framework = arduino
|
|||||||
lib_ldf_mode = deep+
|
lib_ldf_mode = deep+
|
||||||
lib_deps =
|
lib_deps =
|
||||||
WiFiManager=symlink://../..
|
WiFiManager=symlink://../..
|
||||||
|
build_flags =
|
||||||
|
extra_scripts =
|
||||||
|
pre:tools/ota_fixture_input.py
|
||||||
|
|
||||||
[env:esp8266]
|
[env:esp8266]
|
||||||
extends = common
|
extends = common
|
||||||
@@ -27,34 +30,21 @@ build_flags =
|
|||||||
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
|
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
|
||||||
-DWM_LOG_LEVEL=4
|
-DWM_LOG_LEVEL=4
|
||||||
|
|
||||||
; OTA test-harness images deliberately differ only by their compile-time marker.
|
; The OTA test harness writes its A/B marker into an ignored header included
|
||||||
; ESP8266 needs an OTA-capable linker layout; ESP32 needs a tracked two-slot
|
; only by the portal fixture. One environment per platform keeps the explicit
|
||||||
; partition table. The hardware runner always flashes A over serial then
|
; update layout while allowing the A-to-B rebuild to reuse every library object.
|
||||||
; uploads B through the real portal form.
|
[env:esp8266_ota]
|
||||||
[env:esp8266_ota_a]
|
|
||||||
extends = env:esp8266
|
extends = env:esp8266
|
||||||
board_build.ldscript = eagle.flash.4m1m.ld
|
board_build.ldscript = eagle.flash.4m1m.ld
|
||||||
build_flags =
|
build_flags =
|
||||||
${env:esp8266.build_flags}
|
${env:esp8266.build_flags}
|
||||||
-DWM_OTA_TEST_IMAGE=\"A\"
|
${common.build_flags}
|
||||||
|
-DWM_PORTAL_OTA_TEST=1
|
||||||
|
|
||||||
[env:esp8266_ota_b]
|
[env:esp32_ota]
|
||||||
extends = env:esp8266
|
|
||||||
board_build.ldscript = eagle.flash.4m1m.ld
|
|
||||||
build_flags =
|
|
||||||
${env:esp8266.build_flags}
|
|
||||||
-DWM_OTA_TEST_IMAGE=\"B\"
|
|
||||||
|
|
||||||
[env:esp32_ota_a]
|
|
||||||
extends = env:esp32
|
extends = env:esp32
|
||||||
board_build.partitions = partitions/esp32_ota_4m_no_fs.csv
|
board_build.partitions = partitions/esp32_ota_4m_no_fs.csv
|
||||||
build_flags =
|
build_flags =
|
||||||
${env:esp32.build_flags}
|
${env:esp32.build_flags}
|
||||||
-DWM_OTA_TEST_IMAGE=\"A\"
|
${common.build_flags}
|
||||||
|
-DWM_PORTAL_OTA_TEST=1
|
||||||
[env:esp32_ota_b]
|
|
||||||
extends = env:esp32
|
|
||||||
board_build.partitions = partitions/esp32_ota_4m_no_fs.csv
|
|
||||||
build_flags =
|
|
||||||
${env:esp32.build_flags}
|
|
||||||
-DWM_OTA_TEST_IMAGE=\"B\"
|
|
||||||
|
|||||||
@@ -3,19 +3,21 @@
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
// These markers belong only to the portal hardware fixture. They are compiled
|
// These markers belong only to the portal OTA fixture. The hardware runner
|
||||||
// into the image rather than stored in Wi-FiManager settings, so an A -> B
|
// writes the ignored header immediately before each A/B build, so the marker
|
||||||
// assertion proves that the new firmware booted after the updater restarted
|
// is compiled into firmware rather than saved in WiFiManager settings.
|
||||||
// the board. Normal portal test-harness builds retain a descriptive fixture
|
#if defined(WM_PORTAL_OTA_TEST)
|
||||||
// value.
|
#include <ota_fixture_identity.h>
|
||||||
#if defined(WM_OTA_TEST_IMAGE)
|
#ifndef WM_OTA_FIXTURE_IMAGE
|
||||||
|
#error "Portal OTA fixture identity is missing."
|
||||||
|
#endif
|
||||||
// PlatformIO releases the serial port only after the upload-triggered reset.
|
// PlatformIO releases the serial port only after the upload-triggered reset.
|
||||||
// The physical OTA test harness then attaches passively, so give it the same
|
// 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
|
// explicit window as the DeviceFramework A/B fixtures before boot evidence or
|
||||||
// portal work begins. Normal portal test-harness builds keep the short delay.
|
// portal work begins. Normal portal test-harness builds keep the short delay.
|
||||||
constexpr unsigned long kSerialMonitorAttachDelayMs = 5000UL;
|
constexpr unsigned long kSerialMonitorAttachDelayMs = 5000UL;
|
||||||
#else
|
#else
|
||||||
#define WM_OTA_TEST_IMAGE "portal-harness"
|
#define WM_OTA_FIXTURE_IMAGE "portal-harness"
|
||||||
constexpr unsigned long kSerialMonitorAttachDelayMs = 300UL;
|
constexpr unsigned long kSerialMonitorAttachDelayMs = 300UL;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -87,7 +89,7 @@ void registerOtaTestMarker() {
|
|||||||
server->on("/api/test/firmware-marker", HTTP_GET,
|
server->on("/api/test/firmware-marker", HTTP_GET,
|
||||||
[](AsyncWebServerRequest* request) {
|
[](AsyncWebServerRequest* request) {
|
||||||
String response = F("{\"marker\":\"");
|
String response = F("{\"marker\":\"");
|
||||||
response += WM_OTA_TEST_IMAGE;
|
response += WM_OTA_FIXTURE_IMAGE;
|
||||||
response += F("\",\"freeSketchSpace\":");
|
response += F("\",\"freeSketchSpace\":");
|
||||||
response += String(ESP.getFreeSketchSpace());
|
response += String(ESP.getFreeSketchSpace());
|
||||||
response += F("}");
|
response += F("}");
|
||||||
@@ -105,7 +107,7 @@ void setup() {
|
|||||||
// and after its browser upload. It cannot be faked by saved portal values
|
// and after its browser upload. It cannot be faked by saved portal values
|
||||||
// or an HTTP response from a stale image.
|
// or an HTTP response from a stale image.
|
||||||
Serial.print(F("WiFiManager portal OTA fixture image: "));
|
Serial.print(F("WiFiManager portal OTA fixture image: "));
|
||||||
Serial.println(WM_OTA_TEST_IMAGE);
|
Serial.println(WM_OTA_FIXTURE_IMAGE);
|
||||||
|
|
||||||
// This fixture must be independent of whichever sketch was previously
|
// This fixture must be independent of whichever sketch was previously
|
||||||
// flashed to the board. Clear saved station credentials before starting
|
// flashed to the board. Clear saved station credentials before starting
|
||||||
|
|||||||
@@ -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])
|
||||||
+40
-6
@@ -16,6 +16,9 @@ import serial
|
|||||||
|
|
||||||
|
|
||||||
_stop_requested = False
|
_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):
|
def request_stop(_signum, _frame):
|
||||||
@@ -50,7 +53,15 @@ def write_ready(path):
|
|||||||
os.close(descriptor)
|
os.close(descriptor)
|
||||||
|
|
||||||
|
|
||||||
def capture(port, output, ready_file, should_stop=None):
|
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."""
|
"""Append serial bytes until terminated by the owning hardware runner."""
|
||||||
global _stop_requested
|
global _stop_requested
|
||||||
_stop_requested = False
|
_stop_requested = False
|
||||||
@@ -64,15 +75,34 @@ def capture(port, output, ready_file, should_stop=None):
|
|||||||
):
|
):
|
||||||
os.fchmod(output_file.fileno(), 0o600)
|
os.fchmod(output_file.fileno(), 0o600)
|
||||||
write_ready(ready_file)
|
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():
|
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)
|
data = serial_port.read(4096)
|
||||||
if data:
|
if data:
|
||||||
|
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
|
||||||
output_file.write(data)
|
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:
|
else:
|
||||||
# PySerial normally blocks for its configured timeout. A
|
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
|
||||||
# broken backend or test double can return immediately;
|
|
||||||
# never let that turn a detached recorder into a CPU loop.
|
|
||||||
time.sleep(0.01)
|
|
||||||
except (OSError, serial.SerialException) as error:
|
except (OSError, serial.SerialException) as error:
|
||||||
print(f"Passive serial capture failed: {error}", file=sys.stderr)
|
print(f"Passive serial capture failed: {error}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
@@ -84,11 +114,15 @@ def main():
|
|||||||
parser.add_argument("--port", required=True)
|
parser.add_argument("--port", required=True)
|
||||||
parser.add_argument("--output", required=True)
|
parser.add_argument("--output", required=True)
|
||||||
parser.add_argument("--ready-file", required=True)
|
parser.add_argument("--ready-file", required=True)
|
||||||
|
parser.add_argument("--deadline-seconds", type=float)
|
||||||
args = parser.parse_args()
|
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.SIGTERM, request_stop)
|
||||||
signal.signal(signal.SIGINT, request_stop)
|
signal.signal(signal.SIGINT, request_stop)
|
||||||
return capture(args.port, args.output, args.ready_file)
|
return capture(args.port, args.output, args.ready_file, deadline_seconds=args.deadline_seconds)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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,4 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# shellcheck source=tools/lib/harness-locks.sh
|
||||||
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/harness-locks.sh"
|
||||||
# Shared host-side helpers for the WiFiManager portal hardware test harness.
|
# Shared host-side helpers for the WiFiManager portal hardware test harness.
|
||||||
# They never modify a network interface other than the explicit client adapter.
|
# They never modify a network interface other than the explicit client adapter.
|
||||||
|
|
||||||
@@ -121,22 +123,9 @@ wm_default_route_interface() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wm_acquire_hardware_lock() {
|
wm_acquire_hardware_lock() {
|
||||||
# First-party runners deliberately share this lock: a WiFiManager portal
|
# All ordinary ESP portals use this gateway/subnet. Keep portal commands
|
||||||
# test and a DeviceFramework hardware test can otherwise serial-flash the
|
# mutually exclusive even when they name different boards or adapters.
|
||||||
# same selected board concurrently. Keep the WiFiManager override for
|
wm_harness_lock_portal_network
|
||||||
# isolated tests and let callers redirect the common lock with TMPDIR.
|
|
||||||
local lock_file="${WM_HARDWARE_LOCK_FILE:-${TMPDIR:-/tmp}/deviceframework-hardware-test.lock}"
|
|
||||||
# `ota` deliberately acquires this before it builds firmware, then calls
|
|
||||||
# the shared portal-start helper which also acquires it. Keep that nested
|
|
||||||
# path idempotent so the lock covers the whole A/B test harness rather than
|
|
||||||
# only the serial flash and adapter connection.
|
|
||||||
[[ "${WM_HARDWARE_LOCK_HELD:-no}" == "yes" ]] && return 0
|
|
||||||
exec 9>"$lock_file"
|
|
||||||
flock -n 9 || {
|
|
||||||
echo "Another WiFiManager hardware task is already running; wait for it to finish." >&2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
WM_HARDWARE_LOCK_HELD=yes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wm_require_client_adapter() {
|
wm_require_client_adapter() {
|
||||||
@@ -162,6 +151,7 @@ wm_require_client_adapter() {
|
|||||||
echo "Client adapter is not Wi-Fi: $interface ($device_type)." >&2
|
echo "Client adapter is not Wi-Fi: $interface ($device_type)." >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
wm_harness_lock_wifi_adapter "$interface"
|
||||||
if ! active_connection="$(wm_nmcli -g GENERAL.CONNECTION device show "$interface" 2>/dev/null)"; then
|
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
|
echo "NetworkManager could not inspect the selected portal adapter: $interface" >&2
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
+129
-101
@@ -2,8 +2,17 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
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
|
# shellcheck source=tools/lib/portal-hardware-session.sh
|
||||||
source "$root/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() {
|
usage() {
|
||||||
cat <<'USAGE' >&2
|
cat <<'USAGE' >&2
|
||||||
@@ -47,8 +56,7 @@ station_env=""
|
|||||||
output_dir=""
|
output_dir=""
|
||||||
capture_readme_media="no"
|
capture_readme_media="no"
|
||||||
custom_parameter_stress="no"
|
custom_parameter_stress="no"
|
||||||
ota_environment_a=""
|
ota_environment_name=""
|
||||||
ota_environment_b=""
|
|
||||||
ota_firmware_a=""
|
ota_firmware_a=""
|
||||||
ota_firmware_b=""
|
ota_firmware_b=""
|
||||||
ota_browser_prebuilt="no"
|
ota_browser_prebuilt="no"
|
||||||
@@ -97,37 +105,6 @@ require_ota_serial_capture() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wm_pio_executable() {
|
|
||||||
# Hardware runners are commonly launched over non-interactive SSH, where
|
|
||||||
# a normal PlatformIO installation is not necessarily on PATH. Honor an
|
|
||||||
# explicit override first, then PATH, then PlatformIO's standard venv.
|
|
||||||
# This mirrors DeviceFramework's resolver without changing a user's PATH.
|
|
||||||
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" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
pio_for_portal_environment() {
|
pio_for_portal_environment() {
|
||||||
local environment="$1"
|
local environment="$1"
|
||||||
shift
|
shift
|
||||||
@@ -135,7 +112,7 @@ pio_for_portal_environment() {
|
|||||||
case "$environment" in
|
case "$environment" in
|
||||||
# Keep the maintained Core 3.3.11 graph in the same persistent cache
|
# 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.
|
# as the ESP32 A/B fixture. It is never cleared by this test harness.
|
||||||
esp32|esp32_ota_*)
|
esp32|esp32_ota)
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
wm_pio "$@"
|
wm_pio "$@"
|
||||||
@@ -144,7 +121,7 @@ pio_for_portal_environment() {
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
local core_dir packages_dir cache_dir
|
local core_dir packages_dir cache_dir
|
||||||
core_dir="${WIFIMANAGER_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/wifimanager-platformio/core-3.3.11}"
|
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}"
|
packages_dir="${WIFIMANAGER_PLATFORMIO_PACKAGES_DIR:-$core_dir/packages}"
|
||||||
cache_dir="${WIFIMANAGER_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
|
cache_dir="${WIFIMANAGER_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
|
||||||
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
|
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
|
||||||
@@ -164,6 +141,13 @@ prepare_output_dir() {
|
|||||||
output_dir="$(cd "$output_dir" && pwd)"
|
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() {
|
validate_run_arguments() {
|
||||||
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
|
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
|
||||||
[[ -n "$client_interface" ]] || usage
|
[[ -n "$client_interface" ]] || usage
|
||||||
@@ -309,7 +293,7 @@ restore_station_handoff_fixture() {
|
|||||||
ssid="$(wm_portal_ssid "$platform")" || return 1
|
ssid="$(wm_portal_ssid "$platform")" || return 1
|
||||||
if ! wm_wait_for_portal_ssid "$client_interface" "$ssid" || \
|
if ! wm_wait_for_portal_ssid "$client_interface" "$ssid" || \
|
||||||
! wm_verify_portal_route "$client_interface" || \
|
! wm_verify_portal_route "$client_interface" || \
|
||||||
! wait_for_portal_ready; then
|
! wait_for_portal_scan_ready; then
|
||||||
echo 'The clean portal-only fixture did not return after station hand-off cleanup.' >&2
|
echo 'The clean portal-only fixture did not return after station hand-off cleanup.' >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
@@ -330,14 +314,33 @@ write_readme_media_manifest() {
|
|||||||
chmod 600 "$media_dir/manifest.json"
|
chmod 600 "$media_dir/manifest.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_for_portal_ready() {
|
portal_scan_status() {
|
||||||
# A portal SSID can be visible before its first background scan has
|
curl --interface "$client_interface" --connect-timeout 3 --max-time 5 \
|
||||||
# completed. Wait for that normal portal-start work before the browser
|
--silent --show-error --fail http://192.168.4.1/api/wifi/scan-status 2>/dev/null
|
||||||
# test harness asks the device to perform a second, user-initiated refresh.
|
}
|
||||||
|
|
||||||
|
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
|
local attempt response
|
||||||
for attempt in $(seq 1 45); do
|
for attempt in $(seq 1 45); do
|
||||||
response="$(curl --interface "$client_interface" --connect-timeout 3 --max-time 5 \
|
response="$(portal_scan_status || true)"
|
||||||
--silent --show-error --fail http://192.168.4.1/api/wifi/scan-status 2>/dev/null || 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
|
if [[ "$response" == *'"state":"complete"'* && "$response" == *'"results_valid":true'* ]]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -351,8 +354,18 @@ wait_for_portal_ready() {
|
|||||||
return 1
|
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() {
|
start_portal_session() {
|
||||||
local environment="${1:-$platform}" erase_before_upload="${2:-no}" expected_a_artifact="${3:-}" capture_ota_serial="${4:-no}" ssid reconnect_after_drop="no"
|
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
|
validate_run_arguments
|
||||||
require_common
|
require_common
|
||||||
wm_acquire_hardware_lock
|
wm_acquire_hardware_lock
|
||||||
@@ -362,9 +375,17 @@ start_portal_session() {
|
|||||||
# for an idle adapter and then replace a connection it does not own.
|
# for an idle adapter and then replace a connection it does not own.
|
||||||
wm_prepare_networkmanager_authorization
|
wm_prepare_networkmanager_authorization
|
||||||
wm_require_client_adapter "$client_interface" "$takeover"
|
wm_require_client_adapter "$client_interface" "$takeover"
|
||||||
|
wm_harness_lock_serial_port "$port"
|
||||||
prepare_output_dir
|
prepare_output_dir
|
||||||
ssid="$(wm_portal_ssid "$platform")"
|
ssid="$(wm_portal_ssid "$platform")"
|
||||||
|
|
||||||
|
if [[ -n "$expected_a_artifact" ]]; then
|
||||||
|
# B was built most recently. Restore the generated A identity before
|
||||||
|
# PlatformIO verifies and serial-flashes the immutable A artifact.
|
||||||
|
wm_write_ota_fixture_identity A
|
||||||
|
pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment"
|
||||||
|
ota_assert_a_artifact_matches_build "$expected_a_artifact"
|
||||||
|
fi
|
||||||
if [[ "$erase_before_upload" == "yes" ]]; then
|
if [[ "$erase_before_upload" == "yes" ]]; then
|
||||||
# OTA selection metadata must not survive from a previous fixture run:
|
# OTA selection metadata must not survive from a previous fixture run:
|
||||||
# otherwise a bootloader could select a stale app slot instead of A.
|
# otherwise a bootloader could select a stale app slot instead of A.
|
||||||
@@ -397,26 +418,43 @@ start_portal_session() {
|
|||||||
wm_cleanup_created_connection
|
wm_cleanup_created_connection
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if ! wait_for_portal_ready; then
|
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
|
wm_cleanup_created_connection
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
[[ "$require_scan" == yes ]] || report_initial_portal_scan_status
|
||||||
printf 'Portal connected on %s. Artifacts: %s\n' "$client_interface" "$output_dir"
|
printf 'Portal connected on %s. Artifacts: %s\n' "$client_interface" "$output_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
ota_environment() {
|
ota_environment() {
|
||||||
local image="$1"
|
printf '%s_ota\n' "$platform"
|
||||||
printf '%s_ota_%s\n' "$platform" "$image"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ota_marker_field() {
|
ota_marker_field() {
|
||||||
local response="$1" field="$2"
|
local response="$1" field="$2"
|
||||||
case "$field" in
|
case "$field" in
|
||||||
marker)
|
marker|freeSketchSpace)
|
||||||
sed -n 's/.*"marker"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' <<<"$response"
|
# The marker endpoint is deliberately fixture-only JSON. Parse it
|
||||||
;;
|
# as JSON instead of maintaining a second, fragile copy of its
|
||||||
freeSketchSpace)
|
# wire format in a regular expression.
|
||||||
sed -n 's/.*"freeSketchSpace"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' <<<"$response"
|
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
|
echo "Unknown OTA marker field: $field" >&2
|
||||||
@@ -515,31 +553,46 @@ ota_assert_firmware_fits() {
|
|||||||
printf 'OTA %s image fits the available update space: %s <= %s bytes\n' "$label" "$image_size" "$capacity"
|
printf 'OTA %s image fits the available update space: %s <= %s bytes\n' "$label" "$image_size" "$capacity"
|
||||||
}
|
}
|
||||||
|
|
||||||
prepare_ota_firmware() {
|
build_ota_image() {
|
||||||
local source_a source_b capacity
|
local image="$1" source artifact
|
||||||
ota_environment_a="$(ota_environment a)"
|
case "$image" in
|
||||||
ota_environment_b="$(ota_environment b)"
|
A)
|
||||||
|
artifact="$output_dir/${platform}-portal-ota-a.bin"
|
||||||
# Build and preserve both identities before touching the board. The
|
ota_firmware_a="$artifact"
|
||||||
# browser mounts B read-only; the A artifact is compared against the
|
;;
|
||||||
# PlatformIO build used for serial flashing so a later build cannot turn
|
B)
|
||||||
# the A/B proof into an unrecorded input change.
|
artifact="$output_dir/${platform}-portal-ota-b.bin"
|
||||||
pio_for_portal_environment "$ota_environment_a" run -d "$root/test/portal-harness" -e "$ota_environment_a"
|
ota_firmware_b="$artifact"
|
||||||
pio_for_portal_environment "$ota_environment_b" run -d "$root/test/portal-harness" -e "$ota_environment_b"
|
;;
|
||||||
source_a="$root/test/portal-harness/.pio/build/$ota_environment_a/firmware.bin"
|
*)
|
||||||
source_b="$root/test/portal-harness/.pio/build/$ota_environment_b/firmware.bin"
|
echo "Unknown portal OTA fixture image: $image" >&2
|
||||||
[[ -s "$source_a" && -s "$source_b" ]] || {
|
return 2
|
||||||
echo "PlatformIO did not produce both OTA fixture images." >&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
|
return 1
|
||||||
}
|
}
|
||||||
if cmp -s "$source_a" "$source_b"; then
|
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
|
echo "PlatformIO produced identical OTA A and B images." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
ota_firmware_a="$output_dir/${platform}-portal-ota-a.bin"
|
|
||||||
ota_firmware_b="$output_dir/${platform}-portal-ota-b.bin"
|
|
||||||
install -m 600 "$source_a" "$ota_firmware_a"
|
|
||||||
install -m 600 "$source_b" "$ota_firmware_b"
|
|
||||||
|
|
||||||
if [[ "$platform" == "esp32" ]]; then
|
if [[ "$platform" == "esp32" ]]; then
|
||||||
"$root/tools/check-ota-partitions.sh"
|
"$root/tools/check-ota-partitions.sh"
|
||||||
@@ -551,7 +604,7 @@ prepare_ota_firmware() {
|
|||||||
|
|
||||||
ota_assert_a_artifact_matches_build() {
|
ota_assert_a_artifact_matches_build() {
|
||||||
local expected_artifact="$1" source
|
local expected_artifact="$1" source
|
||||||
source="$root/test/portal-harness/.pio/build/$ota_environment_a/firmware.bin"
|
source="$root/test/portal-harness/.pio/build/$ota_environment_name/firmware.bin"
|
||||||
[[ -s "$expected_artifact" && -s "$source" ]] || {
|
[[ -s "$expected_artifact" && -s "$source" ]] || {
|
||||||
echo "Prepared OTA A artifact or PlatformIO build output is missing." >&2
|
echo "Prepared OTA A artifact or PlatformIO build output is missing." >&2
|
||||||
return 1
|
return 1
|
||||||
@@ -633,6 +686,7 @@ start_ota_serial_capture() {
|
|||||||
--port "$port" \
|
--port "$port" \
|
||||||
--output "$ota_serial_capture_log" \
|
--output "$ota_serial_capture_log" \
|
||||||
--ready-file "$ota_serial_capture_ready" \
|
--ready-file "$ota_serial_capture_ready" \
|
||||||
|
--deadline-seconds 600 \
|
||||||
>>"$ota_serial_capture_status" 2>&1 &
|
>>"$ota_serial_capture_status" 2>&1 &
|
||||||
ota_serial_capture_pid=$!
|
ota_serial_capture_pid=$!
|
||||||
|
|
||||||
@@ -658,30 +712,6 @@ require_ota_serial_capture_running() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_ota_serial_sequence() {
|
|
||||||
python3 - "$ota_serial_capture_log" <<'PY'
|
|
||||||
import sys
|
|
||||||
|
|
||||||
path = sys.argv[1]
|
|
||||||
with open(path, "r", encoding="utf-8", errors="replace") as source:
|
|
||||||
lines = source.readlines()
|
|
||||||
|
|
||||||
def first_after(marker, start=0):
|
|
||||||
for index in range(start, len(lines)):
|
|
||||||
if marker in lines[index]:
|
|
||||||
return index
|
|
||||||
return None
|
|
||||||
|
|
||||||
a = first_after("WiFiManager portal OTA fixture image: A")
|
|
||||||
upload_started = first_after("[OTA] Update file:", (a or 0) + 1)
|
|
||||||
upload_completed = first_after("[OTA] OTA FILE END bytes:", (upload_started or 0) + 1)
|
|
||||||
b = first_after("WiFiManager portal OTA fixture image: B", (upload_completed or 0) + 1)
|
|
||||||
|
|
||||||
if None in (a, upload_started, upload_completed, b):
|
|
||||||
raise SystemExit("Serial portal-OTA evidence is missing its required A/upload/B ordering.")
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
stop_ota_serial_capture() {
|
stop_ota_serial_capture() {
|
||||||
local status=0
|
local status=0
|
||||||
[[ -n "$ota_serial_capture_pid" ]] || return 0
|
[[ -n "$ota_serial_capture_pid" ]] || return 0
|
||||||
@@ -693,10 +723,6 @@ stop_ota_serial_capture() {
|
|||||||
fi
|
fi
|
||||||
ota_serial_capture_pid=""
|
ota_serial_capture_pid=""
|
||||||
(( status == 0 )) || return "$status"
|
(( status == 0 )) || return "$status"
|
||||||
if ! assert_ota_serial_sequence; then
|
|
||||||
echo "Serial portal-OTA evidence did not observe ordered A/upload/B markers: $ota_serial_capture_log" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
finish_portal_session() {
|
finish_portal_session() {
|
||||||
@@ -717,6 +743,7 @@ cleanup_portal_session() {
|
|||||||
# a recursive EXIT trap must not obscure the original status.
|
# a recursive EXIT trap must not obscure the original status.
|
||||||
trap - EXIT HUP INT TERM
|
trap - EXIT HUP INT TERM
|
||||||
stop_ota_serial_capture || true
|
stop_ota_serial_capture || true
|
||||||
|
wm_remove_ota_fixture_identity
|
||||||
if ! restore_station_handoff_fixture; then
|
if ! restore_station_handoff_fixture; then
|
||||||
(( status != 0 )) || status=1
|
(( status != 0 )) || status=1
|
||||||
fi
|
fi
|
||||||
@@ -826,9 +853,10 @@ case "$command_name" in
|
|||||||
wm_require_client_adapter "$client_interface" "$takeover"
|
wm_require_client_adapter "$client_interface" "$takeover"
|
||||||
require_ota_serial_capture
|
require_ota_serial_capture
|
||||||
prepare_output_dir
|
prepare_output_dir
|
||||||
|
prepare_ota_identity_input
|
||||||
prepare_ota_firmware
|
prepare_ota_firmware
|
||||||
prepare_ota_harness_image
|
prepare_ota_harness_image
|
||||||
start_portal_session "$ota_environment_a" yes "$ota_firmware_a" yes
|
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
|
# A must be visible through the real portal before a B upload can be
|
||||||
# meaningful. On ESP8266 the marker also reports the actual active
|
# meaningful. On ESP8266 the marker also reports the actual active
|
||||||
|
|||||||
@@ -73,6 +73,26 @@ class FakeSerial:
|
|||||||
self.close()
|
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():
|
def load_capture_module():
|
||||||
fake_serial_module = types.ModuleType("serial")
|
fake_serial_module = types.ModuleType("serial")
|
||||||
fake_serial_module.Serial = FakeSerial
|
fake_serial_module.Serial = FakeSerial
|
||||||
@@ -126,6 +146,57 @@ def main():
|
|||||||
assert ("open", False, False, "/dev/fake") in FakeSerial.events
|
assert ("open", False, False, "/dev/fake") in FakeSerial.events
|
||||||
assert ("close",) in FakeSerial.events
|
assert ("close",) in FakeSerial.events
|
||||||
assert FakeSerial.read_calls == 2
|
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")
|
print("WiFiManager passive OTA serial-capture test-harness check passed")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 ]]
|
||||||
|
rg -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
|
||||||
|
rg -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
|
||||||
|
rg -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"
|
||||||
Reference in New Issue
Block a user