fix: harden portal OTA release

This commit is contained in:
2026-09-20 19:44:05 +10:00
parent 7c5b50499e
commit 360b7cff86
20 changed files with 621 additions and 198 deletions
+2
View File
@@ -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
+4
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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.
+3 -1
View File
@@ -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
View File
@@ -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
+8 -6
View File
@@ -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 \
+12 -22
View File
@@ -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\"
+11 -9
View File
@@ -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
View File
@@ -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__":
+63
View File
@@ -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"
}
+74
View File
@@ -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
}
+31
View File
@@ -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" "$@"
}
+6 -16
View File
@@ -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
View File
@@ -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
+71
View File
@@ -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")
+53
View File
@@ -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"