Files
WiFiManager/tools/portal-hardware
T

565 lines
22 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=tools/lib/portal-hardware-session.sh
source "$root/tools/lib/portal-hardware-session.sh"
usage() {
cat <<'USAGE' >&2
Usage:
./tools/portal-hardware doctor --client-interface IFACE [--take-over-client-adapter]
./tools/portal-hardware up --platform esp8266|esp32 --port /dev/serial/by-id/... \
--client-interface IFACE [--take-over-client-adapter] [--output DIRECTORY]
./tools/portal-hardware run --platform esp8266|esp32 --port /dev/serial/by-id/... \
--client-interface IFACE [--take-over-client-adapter] [--keep] \
[--browser auto|skip] [--station-env PATH] [--output DIRECTORY] \
[--capture-readme-media] [--custom-parameter-stress]
./tools/portal-hardware ota --platform esp8266|esp32 --port /dev/serial/by-id/... \
--client-interface IFACE [--take-over-client-adapter] [--keep] \
[--output DIRECTORY]
./tools/portal-hardware down
Only the named client interface may be disconnected or reconfigured. The tool
refuses the host default-route interface and preserves the created connection
in a 0600 state file until `down` or normal `run`/`ota` cleanup.
`ota` is an opt-in physical A/B test. It erases the explicitly selected test
board's flash, serial-flashes image A, then uses the real portal browser form
to upload image B. It requires the automatic restart and two independent
B-marker responses; a manual reset never makes the test pass. On success the
selected board remains on B in the portal-only fixture.
USAGE
exit 2
}
command_name="${1:-}"
[[ -n "$command_name" ]] || usage
shift || true
platform=""
port=""
client_interface=""
takeover="no"
keep="no"
browser="auto"
station_env=""
output_dir=""
capture_readme_media="no"
custom_parameter_stress="no"
ota_environment_a=""
ota_environment_b=""
ota_firmware_a=""
ota_firmware_b=""
ota_browser_prebuilt="no"
while [[ $# -gt 0 ]]; do
case "$1" in
--platform) [[ $# -ge 2 ]] || usage; platform="$2"; shift 2 ;;
--port) [[ $# -ge 2 ]] || usage; port="$2"; shift 2 ;;
--client-interface) [[ $# -ge 2 ]] || usage; client_interface="$2"; shift 2 ;;
--take-over-client-adapter) takeover="yes"; shift ;;
--keep) keep="yes"; shift ;;
--browser) [[ $# -ge 2 ]] || usage; browser="$2"; shift 2 ;;
--station-env) [[ $# -ge 2 ]] || usage; station_env="$2"; shift 2 ;;
--output) [[ $# -ge 2 ]] || usage; output_dir="$2"; shift 2 ;;
--capture-readme-media) capture_readme_media="yes"; shift ;;
--custom-parameter-stress) custom_parameter_stress="yes"; shift ;;
*) usage ;;
esac
done
require_common() {
wm_require ip
wm_require nmcli
wm_require pio
wm_require docker
wm_require curl
docker compose version >/dev/null
}
pio_for_portal_environment() {
local environment="$1"
shift
case "$environment" in
# The ESP32 A/B fixture extends the 3.3.11 environment. Its package
# graph must not share a Core directory with the legacy 3.0.5 portal
# fixture, whose tool-esptoolpy package name collides with current
# pioarduino metadata.
esp32_core_3_3_11|esp32_ota_*)
;;
*)
pio "$@"
return
;;
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}"
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 "$@"
}
prepare_output_dir() {
if [[ -z "$output_dir" ]]; then
if [[ "$capture_readme_media" == "yes" ]]; then
output_dir="$root/artifacts/readme-media/$(date -u +%Y%m%dT%H%M%SZ)-$platform"
else
output_dir="$(wm_portal_state_root)/runs/$(date -u +%Y%m%dT%H%M%SZ)-$platform"
fi
fi
install -d -m 700 "$output_dir"
output_dir="$(cd "$output_dir" && pwd)"
}
validate_run_arguments() {
[[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
[[ -n "$client_interface" ]] || usage
[[ -n "$port" && -e "$port" ]] || {
echo "Serial port not found: $port" >&2
exit 1
}
[[ "$browser" == "auto" || "$browser" == "skip" ]] || usage
[[ -z "$station_env" || -r "$station_env" ]] || {
echo "Station environment file is not readable: $station_env" >&2
exit 1
}
if [[ "$capture_readme_media" == "yes" ]]; then
[[ "$command_name" == "run" && "$platform" == "esp32" ]] || {
echo "--capture-readme-media is supported only by run --platform esp32" >&2
exit 2
}
[[ "$browser" == "auto" ]] || {
echo "--capture-readme-media requires --browser auto" >&2
exit 2
}
fi
if [[ "$custom_parameter_stress" == "yes" ]]; then
[[ "$command_name" == "run" && "$platform" == "esp8266" ]] || {
echo "--custom-parameter-stress is supported only by run --platform esp8266" >&2
exit 2
}
[[ "$browser" == "auto" ]] || {
echo "--custom-parameter-stress requires --browser auto" >&2
exit 2
}
fi
if [[ "$command_name" == "ota" ]]; then
[[ "$browser" == "auto" ]] || {
echo "OTA coverage always uses the browser; --browser skip is not supported." >&2
exit 2
}
[[ -z "$station_env" ]] || {
echo "OTA coverage is portal-only and does not accept --station-env." >&2
exit 2
}
[[ "$capture_readme_media" == "no" && "$custom_parameter_stress" == "no" ]] || {
echo "OTA coverage cannot be combined with README media or parameter-stress modes." >&2
exit 2
}
fi
}
write_readme_media_manifest() {
local media_dir="$output_dir/readme-media"
local required
for required in portal-tour.gif portal-overview.png portal-wifi-settings.png; do
[[ -s "$media_dir/$required" ]] || {
echo "README media output is incomplete: $media_dir/$required" >&2
return 1
}
done
printf '{"schema":1,"project":"WiFiManager","kind":"readme-media","status":"passed","platform":"esp32","created_at":"%s"}\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$media_dir/manifest.json"
chmod 600 "$media_dir/manifest.json"
}
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
# contract asks the device to perform a second, user-initiated refresh.
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)"
if [[ "$response" == *'"state":"complete"'* && "$response" == *'"results_valid":true'* ]]; then
return 0
fi
if [[ "$response" == *'"state":"failed"'* || "$response" == *'"state":"timeout"'* ]]; then
echo "Portal background scan did not become ready: $response" >&2
return 1
fi
sleep 1
done
echo 'Portal did not complete its initial Wi-Fi scan within 45 seconds.' >&2
return 1
}
start_portal_session() {
local environment="${1:-$platform}" erase_before_upload="${2:-no}" expected_a_artifact="${3:-}" ssid
validate_run_arguments
require_common
wm_acquire_hardware_lock
wm_require_no_active_session
wm_require_client_adapter "$client_interface" "$takeover"
prepare_output_dir
ssid="$(wm_portal_ssid "$platform")"
if [[ "$erase_before_upload" == "yes" ]]; then
# OTA selection metadata must not survive from a previous fixture run:
# otherwise a bootloader could select a stale app slot instead of A.
pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment" -t erase --upload-port "$port"
fi
if [[ -n "$expected_a_artifact" ]]; then
ota_assert_a_artifact_matches_build "$expected_a_artifact"
fi
pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment" -t upload --upload-port "$port"
if [[ -n "$expected_a_artifact" ]]; then
ota_assert_a_artifact_matches_build "$expected_a_artifact"
fi
if ! wm_create_portal_connection "$client_interface" "$ssid" "default1"; then
return 1
fi
if ! wm_verify_portal_route "$client_interface"; then
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
return 1
fi
if ! wait_for_portal_ready; then
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
return 1
fi
if ! wm_write_state "$client_interface" "$platform" "$WM_PORTAL_CONNECTION_UUID" "$WM_PORTAL_CONNECTION_NAME"; then
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
return 1
fi
printf 'Portal connected on %s. Artifacts: %s\n' "$client_interface" "$output_dir"
}
ota_environment() {
local image="$1"
printf '%s_ota_%s\n' "$platform" "$image"
}
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"
;;
*)
echo "Unknown OTA marker field: $field" >&2
return 2
;;
esac
}
ota_fetch_marker() {
curl --interface "$client_interface" --connect-timeout 1 --max-time 2 \
--silent --show-error http://192.168.4.1/api/test/firmware-marker 2>/dev/null
}
wait_for_ota_marker() {
local expected="$1" deadline response marker
deadline=$((SECONDS + 75))
while (( SECONDS < deadline )); do
response="$(ota_fetch_marker || true)"
marker="$(ota_marker_field "$response" marker)"
if [[ "$marker" == "$expected" ]]; then
printf '%s\n' "$response"
return 0
fi
sleep 0.5
done
echo "Portal OTA fixture did not report marker '$expected' within 75 seconds." >&2
return 1
}
ota_esp32_partition_row() {
local partition="$1" table="$root/test/portal-harness/partitions/esp32_ota_4m_no_fs.csv"
[[ -r "$table" ]] || {
echo "ESP32 OTA partition table is unreadable: $table" >&2
return 1
}
awk -F, -v partition="$partition" '
function trim(value) {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
return value
}
/^[[:space:]]*#/ || NF < 5 { next }
trim($1) == partition {
printf "%s\t%s\t%s\t%s\n", trim($2), trim($3), trim($4), trim($5)
exit
}
' "$table"
}
ota_esp32_require_partition() {
local name="$1" expected_type="$2" expected_subtype="$3" expected_offset="$4" expected_size="$5"
local row type subtype offset size
row="$(ota_esp32_partition_row "$name")"
IFS=$'\t' read -r type subtype offset size <<<"$row"
[[ "$type" == "$expected_type" && "$subtype" == "$expected_subtype" && \
"$offset" == "$expected_offset" && "$size" == "$expected_size" ]] || {
echo "ESP32 OTA partition '$name' must be $expected_type/$expected_subtype at $expected_offset with size $expected_size; found '${row:-missing}'." >&2
return 1
}
}
ota_esp32_slot_size() {
local table="$root/test/portal-harness/partitions/esp32_ota_4m_no_fs.csv"
local value_a value_b
[[ -r "$table" ]] || {
echo "ESP32 OTA partition table is unreadable: $table" >&2
return 1
}
ota_esp32_require_partition nvs data nvs 0x9000 0x5000
ota_esp32_require_partition otadata data ota 0xe000 0x2000
ota_esp32_require_partition app0 app ota_0 0x10000 0x1F0000
ota_esp32_require_partition app1 app ota_1 0x200000 0x1F0000
value_a="$(ota_esp32_partition_row app0 | awk -F '\t' '{ print $4 }')"
value_b="$(ota_esp32_partition_row app1 | awk -F '\t' '{ print $4 }')"
[[ "$value_a" =~ ^0x[0-9A-Fa-f]+$ && "$value_a" == "$value_b" ]] || {
echo "ESP32 OTA app slots must have equal hexadecimal capacities: app0=$value_a app1=$value_b" >&2
return 1
}
printf '%d\n' "$((value_b))"
}
ota_assert_firmware_fits() {
local firmware="$1" capacity="$2" label="$3" image_size
[[ -n "$firmware" && -s "$firmware" ]] || {
echo "OTA $label firmware artifact is missing." >&2
return 1
}
[[ "$capacity" =~ ^[0-9]+$ && "$capacity" -gt 0 ]] || {
echo "OTA capacity is invalid: $capacity" >&2
return 1
}
image_size="$(wc -c < "$firmware")"
if (( image_size > capacity )); then
echo "OTA $label image is too large: ${image_size} bytes exceeds ${capacity} bytes." >&2
return 1
fi
printf 'OTA %s image fits the available update space: %s <= %s bytes\n' "$label" "$image_size" "$capacity"
}
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
return 1
}
if cmp -s "$source_a" "$source_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"
capacity="$(ota_esp32_slot_size)"
ota_assert_firmware_fits "$ota_firmware_a" "$capacity" A
ota_assert_firmware_fits "$ota_firmware_b" "$capacity" B
fi
}
ota_assert_a_artifact_matches_build() {
local expected_artifact="$1" source
source="$root/test/portal-harness/.pio/build/$ota_environment_a/firmware.bin"
[[ -s "$expected_artifact" && -s "$source" ]] || {
echo "Prepared OTA A artifact or PlatformIO build output is missing." >&2
return 1
}
cmp -s "$expected_artifact" "$source" || {
echo "PlatformIO's A build no longer matches the immutable artifact prepared before serial flashing." >&2
return 1
}
}
validate_running_ota_capacity() {
local marker_response free_sketch_space capacity
[[ "$platform" == "esp8266" ]] || return 0
marker_response="$(wait_for_ota_marker A)"
free_sketch_space="$(ota_marker_field "$marker_response" freeSketchSpace)"
[[ "$free_sketch_space" =~ ^[0-9]+$ && "$free_sketch_space" -gt 4096 ]] || {
echo "ESP8266 fixture did not report usable free sketch space." >&2
return 1
}
# Match WiFiManagerHandlers::handleUpdating(): Update.begin receives this
# aligned value, not the raw ESP.getFreeSketchSpace() number.
capacity=$(( (free_sketch_space - 0x1000) & 0xFFFFF000 ))
ota_assert_firmware_fits "$ota_firmware_b" "$capacity" B
}
configure_ota_contract_environment() {
export PORTAL_ARTIFACT_DIR="$output_dir"
export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)"
export PORTAL_BROWSER_MODE=auto
# OTA is its own contract. Do not inherit a developer's unrelated media,
# stress, target, or test-selection setting into a physical firmware run.
export PORTAL_CONTRACT_DOCKER_TARGET=portal-contract
export PORTAL_CAPTURE_README_MEDIA=0
export PORTAL_CUSTOM_PARAMETER_STRESS=0
export PORTAL_TEST_FILE=tests/ota.spec.js
export PORTAL_PLATFORM="$platform"
export PORTAL_OTA_FIRMWARE_HOST="$ota_firmware_b"
export PORTAL_OTA_INITIAL_MARKER=A
export PORTAL_OTA_EXPECTED_MARKER=B
}
ota_compose_files() {
printf '%s\n' \
-f "$root/tests/portal-contract/compose.yaml" \
-f "$root/tests/portal-contract/compose.ota.yaml"
}
prepare_ota_contract_image() {
local -a compose_files
configure_ota_contract_environment
mapfile -t compose_files < <(ota_compose_files)
# Build before A is flashed. A cold Playwright build can otherwise consume
# the finite portal window before the browser has connected.
docker compose "${compose_files[@]}" build portal-contract
ota_browser_prebuilt=yes
}
run_ota_contract() {
local -a compose_files
configure_ota_contract_environment
mapfile -t compose_files < <(ota_compose_files)
[[ "$ota_browser_prebuilt" == yes ]] || {
echo "Portal OTA browser image was not prepared before A was flashed." >&2
return 1
}
docker compose "${compose_files[@]}" run --rm portal-contract
}
finish_portal_session() {
wm_load_state
wm_remove_connection "$WM_PORTAL_CONNECTION_UUID"
wm_clear_state
}
case "$command_name" in
doctor)
[[ -n "$client_interface" ]] || usage
require_common
wm_acquire_hardware_lock
wm_require_client_adapter "$client_interface" "$takeover"
printf 'Portal hardware prerequisites are ready. Main route is untouched; client adapter: %s\n' "$client_interface"
;;
up)
start_portal_session
;;
down)
[[ -z "$platform$port$client_interface$station_env$output_dir" ]] || usage
wm_acquire_hardware_lock
finish_portal_session
echo 'Portal client connection removed.'
;;
run)
start_portal_session
cleanup() {
if [[ "$keep" != "yes" ]]; then
finish_portal_session || true
fi
}
trap cleanup EXIT INT TERM
export PORTAL_ARTIFACT_DIR="$output_dir"
export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)"
export PORTAL_BROWSER_MODE="$browser"
# A normal run is the full portal suite; do not let a prior OTA shell
# environment restrict it to one spec.
export PORTAL_TEST_FILE=''
export PORTAL_CUSTOM_PARAMETER_STRESS=0
if [[ "$custom_parameter_stress" == "yes" ]]; then
export PORTAL_CUSTOM_PARAMETER_STRESS=1
fi
export PORTAL_PLATFORM="$platform"
if [[ "$capture_readme_media" == "yes" ]]; then
export PORTAL_CAPTURE_README_MEDIA=1
export PORTAL_CONTRACT_DOCKER_TARGET=media
else
export PORTAL_CAPTURE_README_MEDIA=0
export PORTAL_CONTRACT_DOCKER_TARGET=portal-contract
fi
compose_files=(-f "$root/tests/portal-contract/compose.yaml")
if [[ -n "$station_env" ]]; then
export PORTAL_STATION_ENV_HOST="$(cd "$(dirname "$station_env")" && pwd)/$(basename "$station_env")"
compose_files+=(-f "$root/tests/portal-contract/compose.station.yaml")
fi
# The contract source is copied into the image; rebuild with Docker cache so
# this invocation always tests the checked-out files, not a stale image.
docker compose "${compose_files[@]}" build portal-contract
docker compose "${compose_files[@]}" run --rm portal-contract
if [[ "$capture_readme_media" == "yes" ]]; then
write_readme_media_manifest
fi
printf 'Portal contract passed. Artifacts: %s\n' "$output_dir"
if [[ "$keep" == "yes" ]]; then
printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n'
fi
;;
ota)
# Acquire the same board/adapter lock before *any* A/B preparation.
# Otherwise two OTA invocations can collide in .pio or create a
# same-second artifact directory before either reaches serial flash.
validate_run_arguments
require_common
wm_acquire_hardware_lock
wm_require_no_active_session
wm_require_client_adapter "$client_interface" "$takeover"
prepare_output_dir
prepare_ota_firmware
prepare_ota_contract_image
start_portal_session "$ota_environment_a" yes "$ota_firmware_a"
cleanup() {
if [[ "$keep" != "yes" ]]; then
finish_portal_session || true
fi
}
trap cleanup EXIT INT TERM
# A must be visible through the real portal before a B upload can be
# meaningful. On ESP8266 the marker also reports the actual active
# sketch-space limit, which is checked before the browser sees B.
wait_for_ota_marker A >/dev/null
validate_running_ota_capacity
run_ota_contract
# The browser contract has already required an outage and two B
# observations. Repeat the host-side observation after the container
# exits so the named secondary adapter is also proven to see B.
wait_for_ota_marker B >/dev/null
sleep 1
wait_for_ota_marker B >/dev/null
printf 'Portal HTTP OTA contract passed. Artifacts: %s\n' "$output_dir"
if [[ "$keep" == "yes" ]]; then
printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n'
fi
;;
*) usage ;;
esac