From 7581fa3abd0d83597f411a753851031136ebdab9 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 6 Oct 2026 09:58:40 -0700 Subject: [PATCH] Cover WiFi scan fixes across every ESP32 recipe --- .github/workflows/run-unit-tests.yml | 6 +- build_legacy.sh | 18 ++++ ...tion_heltec_v4_scan_first_ap_2026-10-06.md | 6 +- test/test_esp32_wifi_scan_recipes.py | 83 +++++++++++++++++++ tools/hil/profile_switch.ini | 12 ++- .../esp32_partition_migrator/platformio.ini | 4 +- variants/heltec_v4/platformio.ini | 4 +- variants/xiao_s3_wio/platformio.ini | 8 +- 8 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 test/test_esp32_wifi_scan_recipes.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index cd7a24dab..d955d337f 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -274,7 +274,11 @@ jobs: python3 -B test/test_esp32_boot_file_lifecycle.py -v - name: Verify WiFi OTA startup and WebConfig handoff - run: python3 -B test/test_wifi_ota_start.py -v + run: | + python3 -B test/test_wifi_ota_start.py -v + python3 -B test/test_webconfig_ap_start.py -v + python3 -B test/test_esp32_wifi_scan_fix.py -v + python3 -B test/test_esp32_wifi_scan_recipes.py -v - name: Verify browser OTA completion and immediate HTTP listener restart run: python3 -B test/test_async_web_lifecycle.py -v diff --git a/build_legacy.sh b/build_legacy.sh index 4033a3bf5..8b00059ea 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -3983,6 +3983,21 @@ append_platformio_extra_script() { export PLATFORMIO_EXTRA_SCRIPTS } +ensure_esp32_wifi_scan_fix() { + local env_platform=$1 + local pio_env_name=$2 + local hook="pre:scripts/esp32_wifi_scan_fix.py" + local item + local -a hooks=() + [ "$env_platform" = "ESP32_PLATFORM" ] || return 0 + pio_env_option_contains "$pio_env_name" extra_scripts "scripts/esp32_wifi_scan_fix.py" && return 0 + read -r -a hooks <<< "${PLATFORMIO_EXTRA_SCRIPTS//$'\n'/ }" + for item in "${hooks[@]}"; do + [ "$item" = "$hook" ] && return 0 + done + append_platformio_extra_script "$hook" +} + apply_lora_ota_flag_order_fix() { local env_name=$1 local pio_env_name=$2 @@ -5108,6 +5123,9 @@ build_firmware_one_profile() { unset MESHCORE_COMPANION_RADIO_FULL fi + # Keep the reviewed scan SDK fix when a target overrides extra_scripts. + ensure_esp32_wifi_scan_fix "$env_platform" "$pio_env_name" + if [ "$env_platform" = "ESP32_PLATFORM" ] \ && ! pio_env_option_contains "$pio_env_name" extra_scripts \ "scripts/check_esp32_dram.py"; then diff --git a/docs/test-results/hardware_validation_heltec_v4_scan_first_ap_2026-10-06.md b/docs/test-results/hardware_validation_heltec_v4_scan_first_ap_2026-10-06.md index 090e9c813..022496c55 100644 --- a/docs/test-results/hardware_validation_heltec_v4_scan_first_ap_2026-10-06.md +++ b/docs/test-results/hardware_validation_heltec_v4_scan_first_ap_2026-10-06.md @@ -1,4 +1,4 @@ -# Heltec V4 scan-first AP validation — 2026-10-06 +# Heltec V4 scan-first AP validation - 2026-10-06 The expanded-partition Full repeater is the recommended image for these V4s. Minimal remains a compatibility profile for the smaller portable OTA slot; @@ -48,7 +48,7 @@ ordinary standard profiles retain their existing implementations. | Minimal app | PASS, 1,281,576 bytes; 29,144 bytes spare in the 1,310,720-byte portable slot; 6 capability markers and memory gate passed. | | Minimal SHA-256 | `4f0d55aa3c0c85e76c50a2ba60738294515552dafa5e9496d6a33d73f1072e27` | | Minimal running body hash | `060106D2CA3CA669` | -| Focused host suite | PASS, 65 Python tests, including four compiled AP fixture variants: Arduino 2/3 × open/password AP. | +| Focused host suite | PASS, 65 Python tests, including four compiled AP fixture variants: Arduino 2/3 x open/password AP. | | Build profile tests | PASS, `test/test_build_profiles.sh`. | New AP fixtures exercise stale independent SDK state despite successful facade @@ -106,7 +106,7 @@ Private credentials, scanned network names, and saved secret values are excluded ## Remaining boundaries -- 11–14 mA current consumption is unmeasured. There is no current meter in this +- 11-14 mA current consumption is unmeasured. There is no current meter in this setup; attached native USB and enabled USB logging inhibit light sleep. - Requested ESP-NOW state was checked, but actual packet exchange needs a known second peer. diff --git a/test/test_esp32_wifi_scan_recipes.py b/test/test_esp32_wifi_scan_recipes.py new file mode 100644 index 000000000..ff341d85c --- /dev/null +++ b/test/test_esp32_wifi_scan_recipes.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Resolve all tracked ESP32 scan hooks and execute builder fallback without PIO.""" +import json +from pathlib import Path +import subprocess +import unittest + +from platformio.project.config import ProjectConfig + +ROOT = Path(__file__).resolve().parents[1] +HOOK = "pre:scripts/esp32_wifi_scan_fix.py" + + +class Esp32WiFiScanRecipeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Developer-local overlays must not alter the tracked regression matrix. + cls.config = ProjectConfig(str(ROOT / "platformio.ini"), parse_extra=False) + for path in sorted((ROOT / "variants").glob("*/platformio.ini")): + cls.config.read(str(path), parse_extra=False) + cls.config.read(str(ROOT / "tools/hil/profile_switch.ini"), parse_extra=False) + + def test_every_resolved_esp32_recipe_has_exactly_one_direct_scan_hook(self): + tested = [] + for section in self.config.sections(): + if not section.startswith("env:"): + continue + platform = self.config.get(section, "platform", "").lower() + if "espressif32" not in platform and "pioarduino" not in platform: + continue + with self.subTest(target=section): + hooks = self.config.get(section, "extra_scripts", []) + self.assertEqual(hooks.count(HOOK), 1, hooks) + tested.append(section) + self.assertGreater(len(tested), 400) + for name in ("heltec_v4_partition_migrator", "xiao_s3_partition_legacy_seed", + "esp32_c3_4mb_partition_migrator", "profile_switch_heltec_v4"): + self.assertIn("env:" + name, tested) + + def fallback(self, platform, configured, ambient="", disabled=False): + options = [["env:fixture", [["extra_scripts", configured]]]] + shell = r''' +source "$1/build_legacy.sh" +pio() { printf 'FORBIDDEN_PLATFORMIO\n' >&2; return 99; } +PIO_CONFIG_JSON=$2 +PLATFORMIO_EXTRA_SCRIPTS=$3 +if [ "$5" = 1 ]; then + append_platformio_extra_script() { :; } +fi +ensure_esp32_wifi_scan_fix "$4" fixture +ensure_esp32_wifi_scan_fix "$4" fixture +printf '%s' "$PLATFORMIO_EXTRA_SCRIPTS" +''' + result = subprocess.run(["bash", "-c", shell, "scan-hook", str(ROOT), + json.dumps(options), ambient, platform, + "1" if disabled else "0"], cwd=ROOT, + capture_output=True, text=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("FORBIDDEN_PLATFORMIO", result.stdout + result.stderr) + return result.stdout + + def test_actual_builder_fallback_preserves_overrides_and_is_idempotent(self): + ambient = "pre:scripts/fixture_private.py" + self.assertEqual(self.fallback("ESP32_PLATFORM", [], ambient), ambient + "\n" + HOOK) + self.assertEqual(self.fallback("ESP32_PLATFORM", [HOOK], ambient), ambient) + self.assertEqual(self.fallback("ESP32_PLATFORM", [], ambient + "\n" + HOOK), ambient + "\n" + HOOK) + self.assertEqual(self.fallback("NRF52_PLATFORM", [], ambient), ambient) + + def test_disabled_fallback_cannot_satisfy_missing_hook(self): + self.assertNotIn(HOOK, self.fallback("ESP32_PLATFORM", [], disabled=True)) + self.assertIn(HOOK, self.fallback("ESP32_PLATFORM", [])) + + def test_fallback_precedes_recipe_binding_and_suite_is_wired_in_ci(self): + source = (ROOT / "build_legacy.sh").read_text() + body = source[source.index("build_firmware_one_profile() {"):] + self.assertLess(body.index('ensure_esp32_wifi_scan_fix "$env_platform" "$pio_env_name"'), + body.index("BUILD_RECIPE_SHA256=$(compute_build_recipe_digest")) + self.assertIn(" python3 -B test/test_esp32_wifi_scan_recipes.py -v\n", + (ROOT / ".github/workflows/run-unit-tests.yml").read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/hil/profile_switch.ini b/tools/hil/profile_switch.ini index b1226a6f1..ecec7c227 100644 --- a/tools/hil/profile_switch.ini +++ b/tools/hil/profile_switch.ini @@ -13,7 +13,9 @@ lib_deps = Wire https://github.com/jgromes/RadioLib.git#187ef24791c3d844939b2be13a68bd890bd04e4c rweather/Crypto @ ^0.4.0 -extra_scripts = post:scripts/check_firmware_ram.py +extra_scripts = + pre:scripts/esp32_wifi_scan_fix.py + post:scripts/check_firmware_ram.py build_flags = ${arduino_base.build_flags} -D ESP32_PLATFORM @@ -48,7 +50,9 @@ board_upload.flash_size = 8MB lib_deps = ${env:profile_switch_xiao.lib_deps} lovyan03/LovyanGFX @ ^1.2.7 -extra_scripts = post:scripts/check_firmware_ram.py +extra_scripts = + pre:scripts/esp32_wifi_scan_fix.py + post:scripts/check_firmware_ram.py build_flags = ${arduino_base.build_flags} -D ESP32_PLATFORM @@ -84,7 +88,9 @@ board_upload.flash_size = 16MB board_build.partitions = default_16MB.csv upload_speed = 115200 lib_deps = ${env:profile_switch_xiao.lib_deps} -extra_scripts = post:scripts/check_firmware_ram.py +extra_scripts = + pre:scripts/esp32_wifi_scan_fix.py + post:scripts/check_firmware_ram.py build_flags = ${arduino_base.build_flags} -D ESP32_PLATFORM diff --git a/variants/esp32_partition_migrator/platformio.ini b/variants/esp32_partition_migrator/platformio.ini index 12a6a02bd..0a2a4c82d 100644 --- a/variants/esp32_partition_migrator/platformio.ini +++ b/variants/esp32_partition_migrator/platformio.ini @@ -9,7 +9,9 @@ board_build.partitions = default.csv board_build.flash_mode = dio board_upload.flash_size = 4MB board_upload.maximum_size = 4194304 -extra_scripts = post:scripts/check_firmware_ram.py +extra_scripts = + pre:scripts/esp32_wifi_scan_fix.py + post:scripts/check_firmware_ram.py build_flags = -D ESP32_PLATFORM -D ARDUINO_USB_CDC_ON_BOOT=0 diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index 671bca1c1..f12ef764c 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -897,7 +897,9 @@ board_build.flash_mode = dio ; repeater build restores its normal USB serial setting. ; This standalone image runs its own Wi-Fi OTA server. Reserve the generic ; ESP32 baseline plus its always-on Wi-Fi budget in the linked-image proof. -extra_scripts = post:scripts/check_firmware_ram.py +extra_scripts = + pre:scripts/esp32_wifi_scan_fix.py + post:scripts/check_firmware_ram.py build_flags = -D ESP32_PLATFORM -D MESH_MIN_RUNTIME_HEAP=100352 diff --git a/variants/xiao_s3_wio/platformio.ini b/variants/xiao_s3_wio/platformio.ini index 132270622..1be7f0318 100644 --- a/variants/xiao_s3_wio/platformio.ini +++ b/variants/xiao_s3_wio/platformio.ini @@ -88,7 +88,9 @@ board_build.partitions = default.csv board_build.flash_mode = dio ; This is a standalone ESP32 utility, but it still hosts a Wi-Fi OTA server. ; Reserve the generic ESP32 baseline plus its always-on Wi-Fi budget. -extra_scripts = post:scripts/check_firmware_ram.py +extra_scripts = + pre:scripts/esp32_wifi_scan_fix.py + post:scripts/check_firmware_ram.py build_flags = -D ESP32_PLATFORM -D MESH_MIN_RUNTIME_HEAP=100352 @@ -118,7 +120,9 @@ framework = arduino board_build.partitions = default.csv board_build.flash_mode = dio ; The disposable seed also runs an always-on Wi-Fi OTA server. -extra_scripts = post:scripts/check_firmware_ram.py +extra_scripts = + pre:scripts/esp32_wifi_scan_fix.py + post:scripts/check_firmware_ram.py build_flags = -D ESP32_PLATFORM -D MESH_MIN_RUNTIME_HEAP=100352