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:
|
||||
- uses: actions/checkout@v4
|
||||
- run: bash -n scripts/*.sh tools/check-ota-partitions.sh tools/portal-hardware tools/lib/*.sh tools/tests/*.sh
|
||||
- run: python3 -m py_compile test/portal-harness/tools/ota_fixture_input.py
|
||||
- run: |
|
||||
for spec in tests/portal-harness/tests/*.js; do
|
||||
node --check "$spec"
|
||||
done
|
||||
- run: timeout 15s python3 tools/tests/test-capture-serial.py
|
||||
- run: timeout 15s bash tools/tests/test-ota-fixture-identity.sh
|
||||
- run: ./tools/check-ota-partitions.sh
|
||||
- run: ./scripts/check-docs.sh
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
SYS callback. The updater now enters asynchronous mode before its first
|
||||
erase or write, so the real browser upload can complete and restart into
|
||||
the new firmware image.
|
||||
- Send the successful portal OTA response before scheduling the restart, so
|
||||
browsers can observe a completed HTTP exchange on both ESP8266 and ESP32.
|
||||
- Give ESP32's Wi-Fi radio a five-second hand-off interval before a user scan
|
||||
retry, avoiding transient scan failures immediately after completion.
|
||||
|
||||
## 3.2.4
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ The [Branded Portal](examples/BrandedPortal/) example includes a static SVG, acc
|
||||
```ini
|
||||
[common]
|
||||
lib_deps =
|
||||
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.4
|
||||
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.5
|
||||
```
|
||||
|
||||
The suffix after `#` is a Git ref. PlatformIO clones the repository and checks out that release tag; GitHub Release assets are unrelated. Arduino IDE users can install this repository as a library checkout.
|
||||
|
||||
+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
|
||||
[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
|
||||
the ESP32 A/B fixture,
|
||||
in a dedicated PlatformIO Core/cache directory, defaulting to
|
||||
`${XDG_CACHE_HOME:-$HOME/.cache}/wifimanager-platformio/core-3.3.11`. That
|
||||
keeps pioarduino's package-form `esptool` and generated environment separate
|
||||
`./scripts/test.sh` and `./tools/portal-hardware ota --platform esp32` use the
|
||||
PlatformIO Core/cache shared by the maintained framework repositories,
|
||||
defaulting to `${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11`.
|
||||
WiFiManager, DeviceFramework, DFTE, and ArduinoHA pin this same graph, so this
|
||||
avoids downloading the same Core 3.3.11 inputs for each repository while
|
||||
keeping pioarduino's package-form `esptool` and generated environment separate
|
||||
from stale global `tool-esptoolpy` metadata. Override the location with
|
||||
`WIFIMANAGER_PLATFORMIO_CORE_DIR`,
|
||||
`WIFIMANAGER_PLATFORMIO_PACKAGES_DIR`, and
|
||||
`WIFIMANAGER_PLATFORMIO_CACHE_DIR` when space belongs elsewhere. The first
|
||||
first install is several GiB; reserve at least 4 GiB plus cache headroom. It is
|
||||
persistent and is never cleared by normal test commands.
|
||||
`WIFIMANAGER_PLATFORMIO_CACHE_DIR` when space belongs elsewhere or an isolated
|
||||
diagnosis is needed. The first shared install is several GiB; reserve at least
|
||||
4 GiB plus cache headroom. It is persistent and is never cleared by normal test
|
||||
commands.
|
||||
|
||||
For a disposable cache investigation, point that variable at an exact temporary
|
||||
directory, run the affected command, inspect the resolved graph, then remove
|
||||
|
||||
@@ -26,7 +26,7 @@ void loop() {
|
||||
|
||||
```ini
|
||||
lib_deps =
|
||||
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.4
|
||||
WiFiManager=https://github.com/alexhopeoconnor/WiFiManager.git#v3.2.5
|
||||
```
|
||||
|
||||
The package includes the asynchronous web and TCP dependencies required by the selected ESP8266 or ESP32 target. Add WiFiManager as the application’s direct dependency; do not copy its internal dependency list into your project.
|
||||
|
||||
+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 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 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
|
||||
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
|
||||
intentionally do not require attached hardware, a local network, or Docker.
|
||||
|
||||
Direct PlatformIO test-harness commands default to two compiler jobs. Set
|
||||
`PLATFORMIO_RUN_JOBS=3` only for an explicit local run on an otherwise idle
|
||||
host.
|
||||
|
||||
Physical portal commands lock the shared `192.168.4.0/24` portal network, the
|
||||
selected secondary adapter, and the named serial device. These non-secret
|
||||
resource locks are shared with DeviceFramework's portal harness, so a collision
|
||||
fails before either runner changes a board or adapter while unrelated station
|
||||
tests can use their own resources.
|
||||
|
||||
## Local hardware lifecycle tests
|
||||
|
||||
The Unity suite runs portal-only firmware with no Wi-Fi credentials, MQTT,
|
||||
@@ -242,8 +252,10 @@ HTTP route authentication.
|
||||
|
||||
The test harness performs the following complete run:
|
||||
|
||||
1. Builds immutable A and B fixture images. Their marker is compiled into the
|
||||
binary, not saved in WiFiManager settings or EEPROM.
|
||||
1. Builds immutable A and B fixture images from one platform environment. A
|
||||
generated harness-only header in an ignored, per-run private directory is
|
||||
the only changed input, so their marker is compiled into the binary rather
|
||||
than saved in WiFiManager settings or EEPROM.
|
||||
2. Checks both ESP32 images against the explicit matching `app0`/`app1` slots;
|
||||
ESP8266 validates B after A has booted against the exact aligned capacity
|
||||
passed to `Update.begin()`.
|
||||
@@ -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
|
||||
after serial-flashing A releases the port—before portal association and the A
|
||||
marker check—and remains attached through the two B checks. A passing run
|
||||
requires the log's ordered immutable A marker, WiFiManager's update-start and
|
||||
update-complete lines, then immutable B marker. This preserves firmware-side
|
||||
portal-start and DHCP evidence as well as OTA evidence, without manufacturing a
|
||||
reset. OTA-only fixture images wait five seconds after their upload reset so
|
||||
requires a healthy recorder, but its contents are diagnostic evidence rather
|
||||
than a pass/fail comparison against product log strings. This preserves
|
||||
firmware-side portal-start and DHCP evidence without manufacturing a reset.
|
||||
OTA-only fixture images wait five seconds after their upload reset so
|
||||
the passive recorder can attach before A/B boot evidence is emitted; ordinary
|
||||
portal test-harness startup remains fast.
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ tag="${1:-}"
|
||||
[[ "${2:-}" == "" || "${2:-}" == "--tag" ]] || usage
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=tools/lib/platformio.sh
|
||||
source "$root/tools/lib/platformio.sh"
|
||||
version="${tag#v}"
|
||||
manifest_version="$(sed -n 's/.*"version": "\([^"]*\)".*/\1/p' "$root/library.json" | head -n 1)"
|
||||
|
||||
@@ -54,7 +56,7 @@ validate_reference docs/GETTING_STARTED.md
|
||||
git -C "$root" diff --check
|
||||
package_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$package_dir"' EXIT
|
||||
pio pkg pack "$root" --output "$package_dir/package.tar.gz" >/dev/null
|
||||
wm_pio pkg pack "$root" --output "$package_dir/package.tar.gz" >/dev/null
|
||||
echo "Validated release metadata and PlatformIO package for $tag"
|
||||
|
||||
if [[ "${2:-}" == "--tag" ]]; then
|
||||
|
||||
+60
-20
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Keep standalone compilation predictable on laptops and shared workstations.
|
||||
# A developer may explicitly raise this for an isolated local diagnosis.
|
||||
export PLATFORMIO_RUN_JOBS="${PLATFORMIO_RUN_JOBS:-2}"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE' >&2
|
||||
Usage:
|
||||
@@ -35,30 +39,32 @@ esac
|
||||
[[ "$mode" != "hardware" || -e "$port" ]] || { echo "Serial port not found: $port" >&2; exit 1; }
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=tools/lib/platformio.sh
|
||||
source "$root/tools/lib/platformio.sh"
|
||||
# shellcheck source=tools/lib/ota-fixture-identity.sh
|
||||
source "$root/tools/lib/ota-fixture-identity.sh"
|
||||
|
||||
pio_for_platform() {
|
||||
if [[ "$platform" != "esp32" ]]; then
|
||||
pio "$@"
|
||||
wm_pio "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
# 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
|
||||
# package metadata selecting an incompatible uploader.
|
||||
# cache shared by the maintained framework repositories. It is never
|
||||
# cleared by this script and avoids stale global package metadata selecting
|
||||
# an incompatible uploader without redownloading this same pinned graph.
|
||||
local core_dir packages_dir cache_dir
|
||||
core_dir="${WIFIMANAGER_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/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}"
|
||||
cache_dir="${WIFIMANAGER_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
|
||||
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
|
||||
PLATFORMIO_CORE_DIR="$core_dir" PLATFORMIO_PACKAGES_DIR="$packages_dir" \
|
||||
PLATFORMIO_CACHE_DIR="$cache_dir" pio "$@"
|
||||
PLATFORMIO_CACHE_DIR="$cache_dir" wm_pio "$@"
|
||||
}
|
||||
|
||||
assert_ota_fixture_pair() {
|
||||
local fixture_platform="$1"
|
||||
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"
|
||||
local firmware_a="$1" firmware_b="$2" firmware size capacity
|
||||
[[ -s "$firmware_a" && -s "$firmware_b" ]] || {
|
||||
echo "Portal OTA fixture build did not produce both A and B images." >&2
|
||||
return 1
|
||||
@@ -72,7 +78,7 @@ assert_ota_fixture_pair() {
|
||||
for firmware in "$firmware_a" "$firmware_b"; do
|
||||
size="$(wc -c < "$firmware" | tr -d '[:space:]')"
|
||||
(( 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
|
||||
}
|
||||
done
|
||||
@@ -80,10 +86,11 @@ assert_ota_fixture_pair() {
|
||||
}
|
||||
|
||||
if [[ "$mode" == "hardware" ]]; then
|
||||
# Keep serial flashing and portal-adapter work mutually exclusive.
|
||||
# shellcheck source=tools/lib/portal-hardware-session.sh
|
||||
source "$root/tools/lib/portal-hardware-session.sh"
|
||||
wm_acquire_hardware_lock
|
||||
# Unity uses only the named serial device; it does not own a portal AP or
|
||||
# secondary adapter, so it may run beside an unrelated station test.
|
||||
# shellcheck source=tools/lib/harness-locks.sh
|
||||
source "$root/tools/lib/harness-locks.sh"
|
||||
wm_harness_lock_serial_port "$port"
|
||||
fi
|
||||
|
||||
if [[ "$mode" == "examples" ]]; then
|
||||
@@ -100,14 +107,47 @@ if [[ "$mode" == "examples" ]]; then
|
||||
fi
|
||||
|
||||
if [[ "$mode" == "ota-fixtures" ]]; then
|
||||
fixture_platform="$platform"
|
||||
if [[ "$fixture_platform" == "esp32" ]]; then
|
||||
fixture_environment="${platform}_ota"
|
||||
if [[ "$platform" == "esp32" ]]; then
|
||||
"$root/tools/check-ota-partitions.sh"
|
||||
fi
|
||||
for image in a b; do
|
||||
pio_for_platform run -d "$root/test/portal-harness" -e "${fixture_platform}_ota_${image}" </dev/null
|
||||
done
|
||||
assert_ota_fixture_pair "$fixture_platform"
|
||||
|
||||
# The A/B marker is the only changed source input. Capture each resulting
|
||||
# binary before rebuilding the same platform environment so CI proves the
|
||||
# update images differ without paying for duplicate dependency builds.
|
||||
fixture_dir="$(mktemp -d "${TMPDIR:-/tmp}/wifimanager-ota-fixtures.XXXXXX")"
|
||||
chmod 700 "$fixture_dir"
|
||||
export WIFIMANAGER_OTA_IDENTITY_DIR="$fixture_dir/identity"
|
||||
fixture_build_dir="$fixture_dir/build/$fixture_environment"
|
||||
wm_lock_ota_fixture_environment "$fixture_environment"
|
||||
wm_write_ota_fixture_identity A
|
||||
fixture_a="$fixture_dir/ota-a.bin"
|
||||
fixture_b="$fixture_dir/ota-b.bin"
|
||||
cleanup_ota_fixture_build() {
|
||||
wm_remove_ota_fixture_identity
|
||||
rm -rf -- "$fixture_dir"
|
||||
}
|
||||
on_ota_fixture_build_signal() {
|
||||
local status="$1"
|
||||
# Remove the generated identity before leaving. EXIT will call the
|
||||
# same idempotent cleanup once more, which is intentional.
|
||||
trap - HUP INT TERM
|
||||
cleanup_ota_fixture_build
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup_ota_fixture_build EXIT
|
||||
trap 'on_ota_fixture_build_signal 129' HUP
|
||||
trap 'on_ota_fixture_build_signal 130' INT
|
||||
trap 'on_ota_fixture_build_signal 143' TERM
|
||||
|
||||
PLATFORMIO_BUILD_DIR="$fixture_dir/build" \
|
||||
pio_for_platform run -d "$root/test/portal-harness" -e "$fixture_environment" </dev/null
|
||||
install -m 600 "$fixture_build_dir/firmware.bin" "$fixture_a"
|
||||
wm_write_ota_fixture_identity B
|
||||
PLATFORMIO_BUILD_DIR="$fixture_dir/build" \
|
||||
pio_for_platform run -d "$root/test/portal-harness" -e "$fixture_environment" </dev/null
|
||||
install -m 600 "$fixture_build_dir/firmware.bin" "$fixture_b"
|
||||
assert_ota_fixture_pair "$fixture_a" "$fixture_b"
|
||||
echo "WiFiManager portal OTA fixture compile check passed for $platform"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -25,12 +25,14 @@ the `WM_NMCLI_AUTH` options.
|
||||
|
||||
## A/B portal OTA fixture
|
||||
|
||||
The same fixture has dedicated `*_ota_a` and `*_ota_b` PlatformIO environments
|
||||
for the physical portal HTTP OTA test harness. A and B differ only by a compiled
|
||||
marker served from the fixture-only `/api/test/firmware-marker` endpoint and an
|
||||
immutable serial boot marker. The test harness requires serial A, updater
|
||||
start/completion, then serial B, so it proves a B boot without trusting saved
|
||||
portal values, EEPROM, or a filename.
|
||||
The physical portal HTTP OTA test harness uses one `*_ota` environment per
|
||||
platform. It writes a harness-only A/B identity header to an ignored, owner-only
|
||||
directory unique to that run before each build, so PlatformIO reuses dependency
|
||||
objects while the fixture-only `/api/test/firmware-marker` endpoint still proves
|
||||
the newly booted image. The test harness requires the real form's successful response, its automatic
|
||||
restart, and two fresh B-marker responses. Passive serial capture is retained
|
||||
for failure diagnosis, but a product log-message wording change cannot turn a
|
||||
successful A-to-B update into a failed test.
|
||||
|
||||
```bash
|
||||
./tools/portal-hardware ota \
|
||||
|
||||
@@ -6,6 +6,9 @@ framework = arduino
|
||||
lib_ldf_mode = deep+
|
||||
lib_deps =
|
||||
WiFiManager=symlink://../..
|
||||
build_flags =
|
||||
extra_scripts =
|
||||
pre:tools/ota_fixture_input.py
|
||||
|
||||
[env:esp8266]
|
||||
extends = common
|
||||
@@ -27,34 +30,21 @@ build_flags =
|
||||
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
|
||||
-DWM_LOG_LEVEL=4
|
||||
|
||||
; OTA test-harness images deliberately differ only by their compile-time marker.
|
||||
; ESP8266 needs an OTA-capable linker layout; ESP32 needs a tracked two-slot
|
||||
; partition table. The hardware runner always flashes A over serial then
|
||||
; uploads B through the real portal form.
|
||||
[env:esp8266_ota_a]
|
||||
; The OTA test harness writes its A/B marker into an ignored header included
|
||||
; only by the portal fixture. One environment per platform keeps the explicit
|
||||
; update layout while allowing the A-to-B rebuild to reuse every library object.
|
||||
[env:esp8266_ota]
|
||||
extends = env:esp8266
|
||||
board_build.ldscript = eagle.flash.4m1m.ld
|
||||
build_flags =
|
||||
${env:esp8266.build_flags}
|
||||
-DWM_OTA_TEST_IMAGE=\"A\"
|
||||
${common.build_flags}
|
||||
-DWM_PORTAL_OTA_TEST=1
|
||||
|
||||
[env:esp8266_ota_b]
|
||||
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]
|
||||
[env:esp32_ota]
|
||||
extends = env:esp32
|
||||
board_build.partitions = partitions/esp32_ota_4m_no_fs.csv
|
||||
build_flags =
|
||||
${env:esp32.build_flags}
|
||||
-DWM_OTA_TEST_IMAGE=\"A\"
|
||||
|
||||
[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\"
|
||||
${common.build_flags}
|
||||
-DWM_PORTAL_OTA_TEST=1
|
||||
|
||||
@@ -3,19 +3,21 @@
|
||||
|
||||
namespace {
|
||||
|
||||
// These markers belong only to the portal hardware fixture. They are compiled
|
||||
// into the image rather than stored in Wi-FiManager settings, so an A -> B
|
||||
// assertion proves that the new firmware booted after the updater restarted
|
||||
// the board. Normal portal test-harness builds retain a descriptive fixture
|
||||
// value.
|
||||
#if defined(WM_OTA_TEST_IMAGE)
|
||||
// These markers belong only to the portal OTA fixture. The hardware runner
|
||||
// writes the ignored header immediately before each A/B build, so the marker
|
||||
// is compiled into firmware rather than saved in WiFiManager settings.
|
||||
#if defined(WM_PORTAL_OTA_TEST)
|
||||
#include <ota_fixture_identity.h>
|
||||
#ifndef WM_OTA_FIXTURE_IMAGE
|
||||
#error "Portal OTA fixture identity is missing."
|
||||
#endif
|
||||
// PlatformIO releases the serial port only after the upload-triggered reset.
|
||||
// The physical OTA test harness then attaches passively, so give it the same
|
||||
// explicit window as the DeviceFramework A/B fixtures before boot evidence or
|
||||
// portal work begins. Normal portal test-harness builds keep the short delay.
|
||||
constexpr unsigned long kSerialMonitorAttachDelayMs = 5000UL;
|
||||
#else
|
||||
#define WM_OTA_TEST_IMAGE "portal-harness"
|
||||
#define WM_OTA_FIXTURE_IMAGE "portal-harness"
|
||||
constexpr unsigned long kSerialMonitorAttachDelayMs = 300UL;
|
||||
#endif
|
||||
|
||||
@@ -87,7 +89,7 @@ void registerOtaTestMarker() {
|
||||
server->on("/api/test/firmware-marker", HTTP_GET,
|
||||
[](AsyncWebServerRequest* request) {
|
||||
String response = F("{\"marker\":\"");
|
||||
response += WM_OTA_TEST_IMAGE;
|
||||
response += WM_OTA_FIXTURE_IMAGE;
|
||||
response += F("\",\"freeSketchSpace\":");
|
||||
response += String(ESP.getFreeSketchSpace());
|
||||
response += F("}");
|
||||
@@ -105,7 +107,7 @@ void setup() {
|
||||
// and after its browser upload. It cannot be faked by saved portal values
|
||||
// or an HTTP response from a stale image.
|
||||
Serial.print(F("WiFiManager portal OTA fixture image: "));
|
||||
Serial.println(WM_OTA_TEST_IMAGE);
|
||||
Serial.println(WM_OTA_FIXTURE_IMAGE);
|
||||
|
||||
// This fixture must be independent of whichever sketch was previously
|
||||
// flashed to the board. Clear saved station credentials before starting
|
||||
|
||||
@@ -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
|
||||
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):
|
||||
@@ -50,7 +53,15 @@ def write_ready(path):
|
||||
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."""
|
||||
global _stop_requested
|
||||
_stop_requested = False
|
||||
@@ -64,15 +75,34 @@ def capture(port, output, ready_file, should_stop=None):
|
||||
):
|
||||
os.fchmod(output_file.fileno(), 0o600)
|
||||
write_ready(ready_file)
|
||||
deadline = None
|
||||
if deadline_seconds is not None:
|
||||
deadline = clock() + deadline_seconds
|
||||
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
|
||||
while not should_stop():
|
||||
if deadline is not None and clock() >= deadline:
|
||||
print("Passive serial capture reached its deadline.", file=sys.stderr)
|
||||
return 2
|
||||
read_started = clock()
|
||||
data = serial_port.read(4096)
|
||||
if data:
|
||||
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
|
||||
output_file.write(data)
|
||||
continue
|
||||
|
||||
# A real serial port blocks for its configured timeout. A
|
||||
# broken or detached backend can return an empty read
|
||||
# immediately. Back off only in that pathological case, and
|
||||
# reset after real data, so the recorder cannot become a
|
||||
# CPU-bound loop without penalising normal serial timeouts.
|
||||
if clock() - read_started < IMMEDIATE_EMPTY_READ_SECONDS:
|
||||
sleep(empty_read_backoff)
|
||||
empty_read_backoff = min(
|
||||
empty_read_backoff * 2,
|
||||
EMPTY_READ_BACKOFF_MAX_SECONDS,
|
||||
)
|
||||
else:
|
||||
# PySerial normally blocks for its configured timeout. A
|
||||
# broken backend or test double can return immediately;
|
||||
# never let that turn a detached recorder into a CPU loop.
|
||||
time.sleep(0.01)
|
||||
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
|
||||
except (OSError, serial.SerialException) as error:
|
||||
print(f"Passive serial capture failed: {error}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -84,11 +114,15 @@ def main():
|
||||
parser.add_argument("--port", required=True)
|
||||
parser.add_argument("--output", required=True)
|
||||
parser.add_argument("--ready-file", required=True)
|
||||
parser.add_argument("--deadline-seconds", type=float)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.deadline_seconds is not None and args.deadline_seconds <= 0:
|
||||
parser.error("--deadline-seconds must be greater than zero")
|
||||
|
||||
signal.signal(signal.SIGTERM, request_stop)
|
||||
signal.signal(signal.SIGINT, request_stop)
|
||||
return capture(args.port, args.output, args.ready_file)
|
||||
return capture(args.port, args.output, args.ready_file, deadline_seconds=args.deadline_seconds)
|
||||
|
||||
|
||||
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
|
||||
# shellcheck source=tools/lib/harness-locks.sh
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/harness-locks.sh"
|
||||
# Shared host-side helpers for the WiFiManager portal hardware test harness.
|
||||
# They never modify a network interface other than the explicit client adapter.
|
||||
|
||||
@@ -121,22 +123,9 @@ wm_default_route_interface() {
|
||||
}
|
||||
|
||||
wm_acquire_hardware_lock() {
|
||||
# First-party runners deliberately share this lock: a WiFiManager portal
|
||||
# test and a DeviceFramework hardware test can otherwise serial-flash the
|
||||
# same selected board concurrently. Keep the WiFiManager override for
|
||||
# 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
|
||||
# All ordinary ESP portals use this gateway/subnet. Keep portal commands
|
||||
# mutually exclusive even when they name different boards or adapters.
|
||||
wm_harness_lock_portal_network
|
||||
}
|
||||
|
||||
wm_require_client_adapter() {
|
||||
@@ -162,6 +151,7 @@ wm_require_client_adapter() {
|
||||
echo "Client adapter is not Wi-Fi: $interface ($device_type)." >&2
|
||||
return 1
|
||||
}
|
||||
wm_harness_lock_wifi_adapter "$interface"
|
||||
if ! active_connection="$(wm_nmcli -g GENERAL.CONNECTION device show "$interface" 2>/dev/null)"; then
|
||||
echo "NetworkManager could not inspect the selected portal adapter: $interface" >&2
|
||||
return 1
|
||||
|
||||
+129
-101
@@ -2,8 +2,17 @@
|
||||
set -euo pipefail
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=tools/lib/platformio.sh
|
||||
source "$root/tools/lib/platformio.sh"
|
||||
# shellcheck source=tools/lib/portal-hardware-session.sh
|
||||
source "$root/tools/lib/portal-hardware-session.sh"
|
||||
# shellcheck source=tools/lib/ota-fixture-identity.sh
|
||||
source "$root/tools/lib/ota-fixture-identity.sh"
|
||||
|
||||
# Portal tests exclusively own their portal network, selected Wi-Fi adapter,
|
||||
# and named serial port. Avoid consuming every host core unless the developer
|
||||
# explicitly opts in.
|
||||
export PLATFORMIO_RUN_JOBS="${PLATFORMIO_RUN_JOBS:-2}"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE' >&2
|
||||
@@ -47,8 +56,7 @@ station_env=""
|
||||
output_dir=""
|
||||
capture_readme_media="no"
|
||||
custom_parameter_stress="no"
|
||||
ota_environment_a=""
|
||||
ota_environment_b=""
|
||||
ota_environment_name=""
|
||||
ota_firmware_a=""
|
||||
ota_firmware_b=""
|
||||
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() {
|
||||
local environment="$1"
|
||||
shift
|
||||
@@ -135,7 +112,7 @@ pio_for_portal_environment() {
|
||||
case "$environment" in
|
||||
# Keep the maintained Core 3.3.11 graph in the same persistent cache
|
||||
# as the ESP32 A/B fixture. It is never cleared by this test harness.
|
||||
esp32|esp32_ota_*)
|
||||
esp32|esp32_ota)
|
||||
;;
|
||||
*)
|
||||
wm_pio "$@"
|
||||
@@ -144,7 +121,7 @@ pio_for_portal_environment() {
|
||||
esac
|
||||
|
||||
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}"
|
||||
cache_dir="${WIFIMANAGER_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
|
||||
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
|
||||
@@ -164,6 +141,13 @@ prepare_output_dir() {
|
||||
output_dir="$(cd "$output_dir" && pwd)"
|
||||
}
|
||||
|
||||
prepare_ota_identity_input() {
|
||||
# The generated A/B marker belongs to this run, never to shared fixture
|
||||
# source. Keeping it with the private artifacts prevents cross-run drift.
|
||||
export WIFIMANAGER_OTA_IDENTITY_DIR="$output_dir/ota-fixture-input"
|
||||
install -d -m 700 "$WIFIMANAGER_OTA_IDENTITY_DIR"
|
||||
}
|
||||
|
||||
validate_run_arguments() {
|
||||
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
|
||||
[[ -n "$client_interface" ]] || usage
|
||||
@@ -309,7 +293,7 @@ restore_station_handoff_fixture() {
|
||||
ssid="$(wm_portal_ssid "$platform")" || return 1
|
||||
if ! wm_wait_for_portal_ssid "$client_interface" "$ssid" || \
|
||||
! wm_verify_portal_route "$client_interface" || \
|
||||
! wait_for_portal_ready; then
|
||||
! wait_for_portal_scan_ready; then
|
||||
echo 'The clean portal-only fixture did not return after station hand-off cleanup.' >&2
|
||||
return 1
|
||||
fi
|
||||
@@ -330,14 +314,33 @@ write_readme_media_manifest() {
|
||||
chmod 600 "$media_dir/manifest.json"
|
||||
}
|
||||
|
||||
wait_for_portal_ready() {
|
||||
# A portal SSID can be visible before its first background scan has
|
||||
# completed. Wait for that normal portal-start work before the browser
|
||||
# test harness asks the device to perform a second, user-initiated refresh.
|
||||
portal_scan_status() {
|
||||
curl --interface "$client_interface" --connect-timeout 3 --max-time 5 \
|
||||
--silent --show-error --fail http://192.168.4.1/api/wifi/scan-status 2>/dev/null
|
||||
}
|
||||
|
||||
wait_for_portal_http_ready() {
|
||||
# The AP can be visible before the portal server has completed startup.
|
||||
# OTA requires the update UI and its HTTP route; it does not require an
|
||||
# unrelated background scan to have succeeded.
|
||||
local attempt response
|
||||
for attempt in $(seq 1 45); do
|
||||
response="$(curl --interface "$client_interface" --connect-timeout 3 --max-time 5 \
|
||||
--silent --show-error --fail http://192.168.4.1/api/wifi/scan-status 2>/dev/null || true)"
|
||||
response="$(portal_scan_status || true)"
|
||||
if [[ "$response" == *'"state"'* ]]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo 'Portal HTTP API did not become ready within 45 seconds.' >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_portal_scan_ready() {
|
||||
# The normal portal UI suite exercises the automatic scan and still
|
||||
# requires a usable result. Keep that coverage separate from HTTP OTA.
|
||||
local attempt response
|
||||
for attempt in $(seq 1 45); do
|
||||
response="$(portal_scan_status || true)"
|
||||
if [[ "$response" == *'"state":"complete"'* && "$response" == *'"results_valid":true'* ]]; then
|
||||
return 0
|
||||
fi
|
||||
@@ -351,8 +354,18 @@ wait_for_portal_ready() {
|
||||
return 1
|
||||
}
|
||||
|
||||
report_initial_portal_scan_status() {
|
||||
local response
|
||||
response="$(portal_scan_status || true)"
|
||||
if [[ -n "$response" ]]; then
|
||||
printf 'Initial portal scan status: %s\n' "$response"
|
||||
else
|
||||
echo 'Initial portal scan status was unavailable after portal startup.' >&2
|
||||
fi
|
||||
}
|
||||
|
||||
start_portal_session() {
|
||||
local environment="${1:-$platform}" erase_before_upload="${2:-no}" expected_a_artifact="${3:-}" capture_ota_serial="${4:-no}" 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
|
||||
require_common
|
||||
wm_acquire_hardware_lock
|
||||
@@ -362,9 +375,17 @@ start_portal_session() {
|
||||
# for an idle adapter and then replace a connection it does not own.
|
||||
wm_prepare_networkmanager_authorization
|
||||
wm_require_client_adapter "$client_interface" "$takeover"
|
||||
wm_harness_lock_serial_port "$port"
|
||||
prepare_output_dir
|
||||
ssid="$(wm_portal_ssid "$platform")"
|
||||
|
||||
if [[ -n "$expected_a_artifact" ]]; then
|
||||
# B was built most recently. Restore the generated A identity before
|
||||
# PlatformIO verifies and serial-flashes the immutable A artifact.
|
||||
wm_write_ota_fixture_identity A
|
||||
pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment"
|
||||
ota_assert_a_artifact_matches_build "$expected_a_artifact"
|
||||
fi
|
||||
if [[ "$erase_before_upload" == "yes" ]]; then
|
||||
# OTA selection metadata must not survive from a previous fixture run:
|
||||
# otherwise a bootloader could select a stale app slot instead of A.
|
||||
@@ -397,26 +418,43 @@ start_portal_session() {
|
||||
wm_cleanup_created_connection
|
||||
return 1
|
||||
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
|
||||
return 1
|
||||
fi
|
||||
[[ "$require_scan" == yes ]] || report_initial_portal_scan_status
|
||||
printf 'Portal connected on %s. Artifacts: %s\n' "$client_interface" "$output_dir"
|
||||
}
|
||||
|
||||
ota_environment() {
|
||||
local image="$1"
|
||||
printf '%s_ota_%s\n' "$platform" "$image"
|
||||
printf '%s_ota\n' "$platform"
|
||||
}
|
||||
|
||||
ota_marker_field() {
|
||||
local response="$1" field="$2"
|
||||
case "$field" in
|
||||
marker)
|
||||
sed -n 's/.*"marker"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' <<<"$response"
|
||||
;;
|
||||
freeSketchSpace)
|
||||
sed -n 's/.*"freeSketchSpace"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' <<<"$response"
|
||||
marker|freeSketchSpace)
|
||||
# The marker endpoint is deliberately fixture-only JSON. Parse it
|
||||
# as JSON instead of maintaining a second, fragile copy of its
|
||||
# wire format in a regular expression.
|
||||
python3 -c '
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
value = json.load(sys.stdin)[sys.argv[1]]
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
raise SystemExit(1)
|
||||
|
||||
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
||||
raise SystemExit(1)
|
||||
print(value)
|
||||
' "$field" <<<"$response" 2>/dev/null || true
|
||||
;;
|
||||
*)
|
||||
echo "Unknown OTA marker field: $field" >&2
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
prepare_ota_firmware() {
|
||||
local source_a source_b capacity
|
||||
ota_environment_a="$(ota_environment a)"
|
||||
ota_environment_b="$(ota_environment b)"
|
||||
|
||||
# Build and preserve both identities before touching the board. The
|
||||
# browser mounts B read-only; the A artifact is compared against the
|
||||
# PlatformIO build used for serial flashing so a later build cannot turn
|
||||
# the A/B proof into an unrecorded input change.
|
||||
pio_for_portal_environment "$ota_environment_a" run -d "$root/test/portal-harness" -e "$ota_environment_a"
|
||||
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"
|
||||
[[ -s "$source_a" && -s "$source_b" ]] || {
|
||||
echo "PlatformIO did not produce both OTA fixture images." >&2
|
||||
build_ota_image() {
|
||||
local image="$1" source artifact
|
||||
case "$image" in
|
||||
A)
|
||||
artifact="$output_dir/${platform}-portal-ota-a.bin"
|
||||
ota_firmware_a="$artifact"
|
||||
;;
|
||||
B)
|
||||
artifact="$output_dir/${platform}-portal-ota-b.bin"
|
||||
ota_firmware_b="$artifact"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown portal OTA fixture image: $image" >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
wm_write_ota_fixture_identity "$image"
|
||||
pio_for_portal_environment "$ota_environment_name" run -d "$root/test/portal-harness" -e "$ota_environment_name"
|
||||
source="$root/test/portal-harness/.pio/build/$ota_environment_name/firmware.bin"
|
||||
[[ -s "$source" ]] || {
|
||||
echo "PlatformIO did not produce portal OTA image $image." >&2
|
||||
return 1
|
||||
}
|
||||
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
|
||||
return 1
|
||||
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
|
||||
"$root/tools/check-ota-partitions.sh"
|
||||
@@ -551,7 +604,7 @@ prepare_ota_firmware() {
|
||||
|
||||
ota_assert_a_artifact_matches_build() {
|
||||
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" ]] || {
|
||||
echo "Prepared OTA A artifact or PlatformIO build output is missing." >&2
|
||||
return 1
|
||||
@@ -633,6 +686,7 @@ start_ota_serial_capture() {
|
||||
--port "$port" \
|
||||
--output "$ota_serial_capture_log" \
|
||||
--ready-file "$ota_serial_capture_ready" \
|
||||
--deadline-seconds 600 \
|
||||
>>"$ota_serial_capture_status" 2>&1 &
|
||||
ota_serial_capture_pid=$!
|
||||
|
||||
@@ -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() {
|
||||
local status=0
|
||||
[[ -n "$ota_serial_capture_pid" ]] || return 0
|
||||
@@ -693,10 +723,6 @@ stop_ota_serial_capture() {
|
||||
fi
|
||||
ota_serial_capture_pid=""
|
||||
(( 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() {
|
||||
@@ -717,6 +743,7 @@ cleanup_portal_session() {
|
||||
# a recursive EXIT trap must not obscure the original status.
|
||||
trap - EXIT HUP INT TERM
|
||||
stop_ota_serial_capture || true
|
||||
wm_remove_ota_fixture_identity
|
||||
if ! restore_station_handoff_fixture; then
|
||||
(( status != 0 )) || status=1
|
||||
fi
|
||||
@@ -826,9 +853,10 @@ case "$command_name" in
|
||||
wm_require_client_adapter "$client_interface" "$takeover"
|
||||
require_ota_serial_capture
|
||||
prepare_output_dir
|
||||
prepare_ota_identity_input
|
||||
prepare_ota_firmware
|
||||
prepare_ota_harness_image
|
||||
start_portal_session "$ota_environment_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
|
||||
# meaningful. On ESP8266 the marker also reports the actual active
|
||||
|
||||
@@ -73,6 +73,26 @@ class FakeSerial:
|
||||
self.close()
|
||||
|
||||
|
||||
class FakeClock:
|
||||
def __init__(self):
|
||||
self.value = 0.0
|
||||
|
||||
def __call__(self):
|
||||
return self.value
|
||||
|
||||
def advance(self, seconds):
|
||||
self.value += seconds
|
||||
|
||||
|
||||
class BlockingEmptySerial(FakeSerial):
|
||||
clock = None
|
||||
|
||||
def read(self, _size):
|
||||
type(self).read_calls += 1
|
||||
type(self).clock.advance(0.25)
|
||||
return b""
|
||||
|
||||
|
||||
def load_capture_module():
|
||||
fake_serial_module = types.ModuleType("serial")
|
||||
fake_serial_module.Serial = FakeSerial
|
||||
@@ -126,6 +146,57 @@ def main():
|
||||
assert ("open", False, False, "/dev/fake") in FakeSerial.events
|
||||
assert ("close",) in FakeSerial.events
|
||||
assert FakeSerial.read_calls == 2
|
||||
|
||||
# An immediate empty-return backend must back off progressively instead of
|
||||
# spinning. This takes no real sleep because the sleeper is injected.
|
||||
FakeSerial.read_calls = 0
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
output = Path(temporary_directory) / "serial-ota.log"
|
||||
ready = Path(temporary_directory) / "serial-ota.ready"
|
||||
empty_sleeps = []
|
||||
assert module.capture(
|
||||
"/dev/fake",
|
||||
output,
|
||||
ready,
|
||||
should_stop=lambda: FakeSerial.read_calls >= 5,
|
||||
sleep=empty_sleeps.append,
|
||||
) == 0
|
||||
assert empty_sleeps == [0.01, 0.02, 0.04, 0.08]
|
||||
|
||||
# A normal 250 ms serial timeout is already rate-limited by the device
|
||||
# driver, so it must not receive an additional backoff sleep.
|
||||
module.serial.Serial = BlockingEmptySerial
|
||||
BlockingEmptySerial.events = []
|
||||
BlockingEmptySerial.read_calls = 0
|
||||
clock = FakeClock()
|
||||
BlockingEmptySerial.clock = clock
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
output = Path(temporary_directory) / "serial-ota.log"
|
||||
ready = Path(temporary_directory) / "serial-ota.ready"
|
||||
empty_sleeps = []
|
||||
assert module.capture(
|
||||
"/dev/fake",
|
||||
output,
|
||||
ready,
|
||||
should_stop=lambda: BlockingEmptySerial.read_calls >= 2,
|
||||
clock=clock,
|
||||
sleep=empty_sleeps.append,
|
||||
) == 0
|
||||
assert empty_sleeps == []
|
||||
module.serial.Serial = FakeSerial
|
||||
|
||||
FakeSerial.read_calls = 0
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
output = Path(temporary_directory) / "serial-ota.log"
|
||||
ready = Path(temporary_directory) / "serial-ota.ready"
|
||||
assert module.capture(
|
||||
"/dev/fake",
|
||||
output,
|
||||
ready,
|
||||
should_stop=lambda: False,
|
||||
deadline_seconds=0.0,
|
||||
) == 2
|
||||
|
||||
print("WiFiManager passive OTA serial-capture test-harness check passed")
|
||||
|
||||
|
||||
|
||||
@@ -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