From 2ee62b981435df7dc4bcd79de39e61a4d5e98652 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 6 Oct 2026 09:40:28 -0700 Subject: [PATCH] Fix V4 setup AP startup and pre-AP network scans --- build_legacy.sh | 8 + ...tion_heltec_v4_scan_first_ap_2026-10-06.md | 123 ++++ ...ltec_v4_scan_first_ap_2026-10-06.safe.json | 603 ++++++++++++++++++ platformio.ini | 1 + scripts/esp32_wifi_scan_fix.py | 92 +++ src/helpers/esp32/WebConfigServer.cpp | 125 +++- test/fixtures/esp32_wifi_scan/.gitattributes | 2 + test/fixtures/esp32_wifi_scan/README.txt | 22 + test/fixtures/esp32_wifi_scan/WiFiScan.cpp | 281 ++++++++ .../esp32_wifi_scan/wifi_scan_config.h | 40 ++ test/fixtures/webconfig_ap_start.cpp | 600 +++++++++++++++++ test/test_build_profiles.sh | 46 +- test/test_esp32_wifi_scan_fix.py | 250 ++++++++ test/test_webconfig_ap_start.py | 62 ++ 14 files changed, 2233 insertions(+), 22 deletions(-) create mode 100644 docs/test-results/hardware_validation_heltec_v4_scan_first_ap_2026-10-06.md create mode 100644 docs/test-results/heltec_v4_scan_first_ap_2026-10-06.safe.json create mode 100644 scripts/esp32_wifi_scan_fix.py create mode 100644 test/fixtures/esp32_wifi_scan/.gitattributes create mode 100644 test/fixtures/esp32_wifi_scan/README.txt create mode 100644 test/fixtures/esp32_wifi_scan/WiFiScan.cpp create mode 100644 test/fixtures/esp32_wifi_scan/wifi_scan_config.h create mode 100644 test/fixtures/webconfig_ap_start.cpp create mode 100644 test/test_esp32_wifi_scan_fix.py create mode 100644 test/test_webconfig_ap_start.py diff --git a/build_legacy.sh b/build_legacy.sh index 5013bdb7e..721bf08ab 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -3631,6 +3631,14 @@ apply_esp32_lora_ota_size_profile() { fi export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -UDISABLE_WIFI_OTA -DLIGHTWEIGHT_WIFI_OTA=1 -DMAX_NEIGHBOURS=${max_neighbours} -fno-exceptions" record_build_capability "web.lightweight_browser_ota" + if is_lora_ota_no_external_sensors_target "$env_name"; then + # Preserve both update paths and the complete CLI inside the portable + # slot. These existing compact implementations retain every OTA target + # name and the exact Ed25519 key/signature format; fixed-base signing + # trades more CPU time and stack for the 30 KiB precomputed flash table. + # Full and ordinary standard images retain their normal implementations. + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DED25519_COMPACT_BASE=1 -DED25519_COMPACT_SHA512=1 -DOTA_TARGET_NAME_FRONT_CODED=1" + fi else append_platformio_build_unflags "-DLIGHTWEIGHT_WIFI_OTA=1" export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -ULIGHTWEIGHT_WIFI_OTA -DDISABLE_WIFI_OTA=1" 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 new file mode 100644 index 000000000..090e9c813 --- /dev/null +++ b/docs/test-results/hardware_validation_heltec_v4_scan_first_ap_2026-10-06.md @@ -0,0 +1,123 @@ +# 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; +expanded nodes do not need a second minimal variant. + +Source base: `8d31b3932de614ac38d238f1a01010bc5f362fe8`, with the working changes +described below. `git pull --ff-only` reported already up to date. The tested +version is `v1.17.1.9-halo-keymind-cascade-dev-v4fix8-8d31b393`. +PlatformIO commands ran sequentially in a native WSL checkout. + +## Failures and corrections + +The original latest-source check reproduced a WebConfig AP that reported +startup but could not be discovered by a normal WiFi client. Earlier simulated +tests and CLI status had not proved beacon discovery, association, or the full +browser flow. Those were material coverage gaps against the reported problem. + +WebConfig now removes the previous AP while preserving a shared STA/ESP-NOW +driver, scans in STA-only mode, and retains the results for the first picker +request. Startup verifies live SDK SSID, visibility, authentication, channel, +b/g/n protocol, AP-start event, IP, and absence of a STA association before +reporting success. Startup failures remain bounded by the five-second console +deadline. A failed scan is canceled before AP startup; ordinary picker requests +then return an empty result and permit manual SSID entry. Only explicit Rescan +starts another scan on that setup AP. + +The pinned Arduino 2.0.17 scanner used an uninitialized `wifi_scan_config_t`. +Its active path left passive dwell and home-channel dwell fields indeterminate. +A source/version-checked middleware now zero-initializes the structure in a +private build copy. The shared PlatformIO framework remains unchanged. Before +this correction, channel-zero scans were still running after 3.6 seconds and +were canceled. Afterward, repeated all-channel scans completed in approximately +2.4 seconds and supplied cached results. This hardware comparison supports the +uninitialized configuration as the cause of that timeout. + +The standard ESP32 minimal LoRa OTA profile uses the existing compact Ed25519, +SHA-512, and target-name implementations to fit its portable slot. Full and +ordinary standard profiles retain their existing implementations. + +## Builds and host regressions + +| Check | Result | +| --- | --- | +| Full repeater app | PASS, 2,098,744 bytes in each 6,553,600-byte A/B slot; 10 capability markers and internal memory gate passed. | +| Full SHA-256 | `d534c9d79796886fb84690c784ae985bfef6d808d5e7879409f139e9463f126d` | +| Full running body hash | `8C8A7ADBDA25501E` | +| 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. | +| Build profile tests | PASS, `test/test_build_profiles.sh`. | + +New AP fixtures exercise stale independent SDK state despite successful facade +returns, setter/readback failures, delayed AP events, shared ESP-NOW ownership, +LAN promotion, scan timeout/cancellation, cached picker results, explicit +rescans, and failed-scan manual-entry behavior. Worst modeled startup failure +is 4.59 seconds. A very slow scan can consume the retry budget, so a transient +first AP failure in that case returns a bounded failure rather than retrying. + +Five new middleware tests execute the actual patched `scanNetworks` method +against an SDK capture. A poisoned-local negative control fails with the old +uninitialized source. Tests cover caller parameters, active/passive defaults, +already-running scans, private-copy isolation, and source/version guards. +Eleven existing crypto and target-name tests verify signature compatibility and +preservation of all target names/IDs. + +## Physical checks + +Mercerwood's Heltec V4.3 OLED repeater was identified by its exact USB serial. +Its expanded partition geometry was checked before each application-only OTA +upload. No partition table, bootloader, identity, or ACL was replaced. + +| Boundary | Result | +| --- | --- | +| WebConfig, ESP-NOW off | PASS: fresh discovery, association, gzip loader, full UI, setup API, cached all-channel results, explicit rescan. Startup reply approximately 2.70 seconds. | +| WebConfig, ESP-NOW on | PASS: discovery, association, full UI/API, cached shared-channel results, explicit rescan. Startup reply approximately 0.44 seconds. | +| 2.4 GHz off/on | PASS: master off rejects individual WiFi-on; master on restores WiFi/ESP-NOW; subsequent WebConfig AP works. | +| OTA after master off and repeated startup | PASS: discovery, association, and update page. Subsequent WebConfig AP also works. | +| Actual browser JavaScript | PASS: the unmodified setup wizard opened populated networks automatically; Rescan repopulated them; manual unsaved SSID entry and picker close/reopen worked. | +| Automatic startup after reboot | PASS with WebUI temporarily enabled and no saved SSID: AP discovered/joined and setup API returned cached scan results before any manual start command. Original disabled WebUI setting restored. | +| Minimal WiFi OTA | PASS for three start/stop cycles, completed raw scans with fresh beacon age, exact-BSSID association, and the lightweight uploader page. Its raw uploader installed the supplied Full app and rebooted into its exact hash. | + +Full's seven-case manual run completed without an unexpected CPU reboot. +Minimal cycles also completed without an unexpected reboot. The final node is +back on the Full hash above. All original saved preferences, public identity, +ACL, and runtime radio states were compared after restoration. The Pi's original +WiFi profile and wired route are restored, temporary profiles are removed, +serial ownership is released, and the original service states are unchanged. + +The minimal profile omits manual WiFi/ESP-NOW group controls; their unavailable +responses are recorded rather than counted as master-off recovery. Full carries +that recovery qualification. + +Two test-runner errors are retained in the raw evidence: an early minimal run +could not find `iw` outside sudo's path; the corrected run then incorrectly +expected automatic AP startup while the saved WebUI master was disabled. Its +five minimal/AP/upload checks and Full/settings restoration passed. A separate +enabled fresh-boot run correctly qualified automatic startup. An earlier update +and verification overlap was also corrected by a lock covering the entire Pi +test and cleanup transaction. These failures were not firmware acceptance passes. + +Sanitized physical evidence is in +[`heltec_v4_scan_first_ap_2026-10-06.safe.json`](heltec_v4_scan_first_ap_2026-10-06.safe.json). +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 + 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. +- The reported iPhone client was not available. Linux association and the desktop + browser were tested. +- Saved-network LAN recovery after WiFi off/on was not physically retested; + existing ownership/reconnect host tests cover that branch. This node has no + saved SSID, and no deployment credentials were changed. +- This qualifies one V4 and its pinned Arduino 2.0.17 build. Known Arduino 3 + recipes remain unchanged by the narrow scanner middleware. + +Future acceptance must include fresh discovery, association, usable UI/API, +first cached picker results, explicit rescan, and cleanup on actual hardware; +CLI success and simulated state alone are insufficient. diff --git a/docs/test-results/heltec_v4_scan_first_ap_2026-10-06.safe.json b/docs/test-results/heltec_v4_scan_first_ap_2026-10-06.safe.json new file mode 100644 index 000000000..312a17ec5 --- /dev/null +++ b/docs/test-results/heltec_v4_scan_first_ap_2026-10-06.safe.json @@ -0,0 +1,603 @@ +{ + "reports": { + "install": { + "source": "8d31b3932de614ac38d238f1a01010bc5f362fe8", + "cases": [], + "ok": true, + "current_measured": false, + "flashed": true, + "firmware_sha256": "d534c9d79796886fb84690c784ae985bfef6d808d5e7879409f139e9463f126d", + "expected_body_hash16": "8C8A7ADBDA25501E", + "image_bytes": 2098744, + "scan_diagnostics": [], + "version_before": "v1.17.1.9-halo-keymind-cascade-dev-v4fix7-8d31b393 (Build: 06-Oct-2026)", + "self_before": "self body=2098672 image=2098728 base_hash=655FDD5693EFA830 | maxblk:2048", + "slot_before": "inactive slot addr=0x10000 size=6553600", + "ota_get_before": { + "status": 200, + "body_bytes": 53715, + "body_sha256": "b59f9e0b4af33f83d3ce47718ae5923c21e97361fb87e6c2e6cc4af4fe7ceb4d" + }, + "upload_http_status": 200, + "version_after": "v1.17.1.9-halo-keymind-cascade-dev-v4fix8-8d31b393 (Build: 06-Oct-2026)", + "self_after": "self body=2098688 image=2098744 base_hash=8C8A7ADBDA25501E | maxblk:2048", + "slot_after": "inactive slot addr=0x650000 size=6553600", + "image_verified": true, + "preferences_restored": true, + "identity_restored": true, + "acl_restored": true, + "runtime_restored": true, + "pi_wifi_restored": true, + "cleanup_errors": [] + }, + "verify": { + "source": "8d31b3932de614ac38d238f1a01010bc5f362fe8", + "cases": [ + { + "name": "webconfig_ap_with_espnow_off_fresh_raw_scan", + "passed": true, + "ap_discovered": true, + "association": true, + "scan": { + "raw_scan_status": 0, + "raw_bss_count": 4, + "raw_target_visible": true, + "nm_target_visible": true, + "target_frequencies_mhz": [ + "2412" + ] + }, + "http": { + "status": 200, + "body_bytes": 760, + "body_sha256": "576a21f3595850f4c511782fceec23333eab9e717e22b1d12d919c7ddb85b177", + "scan_first": { + "cached_results_ready": true, + "cached_network_count": 4, + "explicit_rescan_completed": true, + "rescan_network_count": 4 + }, + "browser_ui": { + "status": 200, + "decompressed_bytes": 118226, + "sha256": "397ba8c996d3d0ab85d753526467e7a78107aad50b437ccd3c8dc598c97cb4cd", + "setup_api_verified": true, + "gzip_loader_verified": true + } + }, + "ap_start_reply_ms": 2704.79 + }, + { + "name": "webconfig_ap_with_espnow_on_fresh_raw_scan", + "passed": true, + "ap_discovered": true, + "association": true, + "scan": { + "raw_scan_status": 0, + "raw_bss_count": 5, + "raw_target_visible": true, + "nm_target_visible": true, + "target_frequencies_mhz": [ + "2412" + ] + }, + "http": { + "status": 200, + "body_bytes": 760, + "body_sha256": "576a21f3595850f4c511782fceec23333eab9e717e22b1d12d919c7ddb85b177", + "scan_first": { + "cached_results_ready": true, + "cached_network_count": 3, + "explicit_rescan_completed": true, + "rescan_network_count": 1 + }, + "browser_ui": { + "status": 200, + "decompressed_bytes": 118226, + "sha256": "397ba8c996d3d0ab85d753526467e7a78107aad50b437ccd3c8dc598c97cb4cd", + "setup_api_verified": true, + "gzip_loader_verified": true + } + }, + "ap_start_reply_ms": 443.9 + }, + { + "name": "group_off_then_on_restores_services", + "passed": true, + "master_off_rejects_individual_on": true, + "services_restarted": true + }, + { + "name": "webconfig_ap_after_group_restore_fresh_raw_scan", + "passed": true, + "ap_discovered": true, + "association": true, + "scan": { + "raw_scan_status": 0, + "raw_bss_count": 5, + "raw_target_visible": true, + "nm_target_visible": true, + "target_frequencies_mhz": [ + "2412" + ] + }, + "http": { + "status": 200, + "body_bytes": 760, + "body_sha256": "576a21f3595850f4c511782fceec23333eab9e717e22b1d12d919c7ddb85b177", + "scan_first": { + "cached_results_ready": true, + "cached_network_count": 2, + "explicit_rescan_completed": true, + "rescan_network_count": 2 + }, + "browser_ui": { + "status": 200, + "decompressed_bytes": 118226, + "sha256": "397ba8c996d3d0ab85d753526467e7a78107aad50b437ccd3c8dc598c97cb4cd", + "setup_api_verified": true, + "gzip_loader_verified": true + } + }, + "ap_start_reply_ms": 444.34 + }, + { + "name": "ota_ap_after_group_off_fresh_raw_scan", + "passed": true, + "ap_discovered": true, + "association": true, + "scan": { + "raw_scan_status": 0, + "raw_bss_count": 7, + "raw_target_visible": true, + "nm_target_visible": true, + "target_frequencies_mhz": [ + "2412" + ] + }, + "http": { + "status": 200, + "body_bytes": 169232, + "body_sha256": "2ada609891d6393b54bdcc559bb2f7d2abb564d5cd27593b266706cc8d878ab4" + } + }, + { + "name": "ota_ap_repeat_start_fresh_raw_scan", + "passed": true, + "ap_discovered": true, + "association": true, + "scan": { + "raw_scan_status": 0, + "raw_bss_count": 7, + "raw_target_visible": true, + "nm_target_visible": true, + "target_frequencies_mhz": [ + "2412" + ] + }, + "http": { + "status": 200, + "body_bytes": 169232, + "body_sha256": "2ada609891d6393b54bdcc559bb2f7d2abb564d5cd27593b266706cc8d878ab4" + } + }, + { + "name": "webconfig_ap_after_ota_stop_fresh_raw_scan", + "passed": true, + "ap_discovered": true, + "association": true, + "scan": { + "raw_scan_status": 0, + "raw_bss_count": 7, + "raw_target_visible": true, + "nm_target_visible": true, + "target_frequencies_mhz": [ + "2412" + ] + }, + "http": { + "status": 200, + "body_bytes": 760, + "body_sha256": "576a21f3595850f4c511782fceec23333eab9e717e22b1d12d919c7ddb85b177", + "scan_first": { + "cached_results_ready": true, + "cached_network_count": 2, + "explicit_rescan_completed": true, + "rescan_network_count": 2 + }, + "browser_ui": { + "status": 200, + "decompressed_bytes": 118226, + "sha256": "397ba8c996d3d0ab85d753526467e7a78107aad50b437ccd3c8dc598c97cb4cd", + "setup_api_verified": true, + "gzip_loader_verified": true + } + }, + "ap_start_reply_ms": 443.88 + } + ], + "ok": true, + "current_measured": false, + "flashed": false, + "scan_diagnostics": [ + { + "channel": 0, + "start": -1, + "result": 4, + "elapsed_ms": 2400 + }, + { + "channel": 0, + "start": -1, + "result": 4, + "elapsed_ms": 2401 + }, + { + "channel": 1, + "start": -1, + "result": 3, + "elapsed_ms": 221 + }, + { + "channel": 1, + "start": -1, + "result": 3, + "elapsed_ms": 221 + }, + { + "channel": 1, + "start": -1, + "result": 0, + "elapsed_ms": 120 + }, + { + "channel": 1, + "start": -1, + "result": 3, + "elapsed_ms": 221 + }, + { + "channel": 1, + "start": -1, + "result": 2, + "elapsed_ms": 220 + }, + { + "channel": 1, + "start": -1, + "result": 2, + "elapsed_ms": 221 + }, + { + "channel": 1, + "start": -1, + "result": 2, + "elapsed_ms": 220 + } + ], + "version": "v1.17.1.9-halo-keymind-cascade-dev-v4fix8-8d31b393 (Build: 06-Oct-2026)", + "self": "self body=2098688 image=2098744 base_hash=8C8A7ADBDA25501E | maxblk:2048", + "firmware_sha256": "d534c9d79796886fb84690c784ae985bfef6d808d5e7879409f139e9463f126d", + "bridge_channel": "> 1", + "start_uptime_secs": 28, + "preferences_restored": true, + "identity_restored": true, + "acl_restored": true, + "runtime_restored": true, + "end_uptime_secs": 71, + "no_cpu_reboot": true, + "pi_wifi_restored": true, + "cleanup_errors": [] + }, + "browser": { + "source": "8d31b3932de614ac38d238f1a01010bc5f362fe8", + "expected_version": "v4fix8", + "expected_body_hash16": "8C8A7ADBDA25501E", + "browser_ready": true, + "browser_completed": true, + "browser_evaluated_externally": true, + "current_measured": false, + "flashed": false, + "ok": true, + "scan_diagnostics": [ + { + "channel": 0, + "start": -1, + "result": 4, + "elapsed_ms": 2401 + }, + { + "channel": 0, + "start": -1, + "result": 4, + "elapsed_ms": 2400 + } + ], + "version": "v1.17.1.9-halo-keymind-cascade-dev-v4fix8-8d31b393 (Build: 06-Oct-2026)", + "image_hash_verified": true, + "baseline_saved_ssid_empty": true, + "runtime_baseline_off": true, + "start_uptime_secs": 101, + "ap_start_reply_ms": 2717.07, + "ap_discovered": true, + "association": true, + "raw_target_visible": true, + "nm_target_visible": true, + "browser_hold_secs": 177.2, + "preferences_restored": true, + "identity_restored": true, + "acl_restored": true, + "runtime_restored": true, + "version_restored": true, + "image_hash_restored": true, + "end_uptime_secs": 298, + "no_cpu_reboot": true, + "pi_wifi_restored": true, + "temporary_profiles_removed": true, + "wired_route_restored": true, + "cleanup_errors": [] + }, + "install_minimal": { + "source": "8d31b3932de614ac38d238f1a01010bc5f362fe8", + "cases": [], + "ok": true, + "current_measured": false, + "flashed": true, + "firmware_sha256": "4f0d55aa3c0c85e76c50a2ba60738294515552dafa5e9496d6a33d73f1072e27", + "expected_body_hash16": "060106D2CA3CA669", + "image_bytes": 1281576, + "scan_diagnostics": [], + "version_before": "v1.17.1.9-halo-keymind-cascade-dev-v4fix8-8d31b393 (Build: 06-Oct-2026)", + "self_before": "self body=2098688 image=2098744 base_hash=8C8A7ADBDA25501E | maxblk:2048", + "slot_before": "inactive slot addr=0x650000 size=6553600", + "ota_get_before": { + "status": 200, + "body_bytes": 53715, + "body_sha256": "b59f9e0b4af33f83d3ce47718ae5923c21e97361fb87e6c2e6cc4af4fe7ceb4d" + }, + "upload_http_status": 200, + "version_after": "v1.17.1.9-halo-keymind-cascade-dev-v4fix8-8d31b393 (Build: 06-Oct-2026)", + "self_after": "self body=1281520 image=1281576 base_hash=060106D2CA3CA669 | maxblk:2048", + "slot_after": "inactive slot addr=0x10000 size=6553600", + "image_verified": true, + "compile_dependent_fields": [ + "get usb.logging", + "get logging.output", + "get mqtt.enabled", + "get webui", + "get wifi.ssid", + "get wifi.powersave", + "get espnow.enabled" + ], + "preferences_restored": true, + "identity_restored": true, + "acl_restored": true, + "profile_radio_controls": { + "get wifi": "wifi unavailable", + "get espnow": "espnow unavailable" + }, + "runtime_restored": null, + "ota_stop_requested": true, + "pi_wifi_restored": true, + "cleanup_errors": [] + }, + "minimal": { + "source": "8d31b3932de614ac38d238f1a01010bc5f362fe8", + "profile": "heltec_v4_repeater_lora_ota_no_external_sensors", + "cases": [ + { + "name": "minimal_start_ota_ap_after_stop_and_master_off_attempt", + "passed": true, + "ap_discovered": true, + "association": true, + "fresh_scan": { + "scan_status": 0, + "bssid_sha256": "1287bbb9c9aecd02ff94be170a225216154149c63384369ca9f9023f7a15623b", + "last_seen_ms": 0, + "frequency_mhz": 2412 + }, + "http": { + "status": 200, + "decompressed_bytes": 1078, + "body_sha256": "7379d4c0bcc34c9a277c7faac9d97735693ee2a2fc27408ca81054433150a4d2", + "lightweight_uploader": true + }, + "start_command": "start ota ap", + "master_off_control_supported": false, + "precondition": "OTA stopped; minimal group controls unavailable" + }, + { + "name": "minimal_repeat_ota_ap_after_stop", + "passed": true, + "ap_discovered": true, + "association": true, + "fresh_scan": { + "scan_status": 0, + "bssid_sha256": "1287bbb9c9aecd02ff94be170a225216154149c63384369ca9f9023f7a15623b", + "last_seen_ms": 0, + "frequency_mhz": 2412 + }, + "http": { + "status": 200, + "decompressed_bytes": 1078, + "body_sha256": "7379d4c0bcc34c9a277c7faac9d97735693ee2a2fc27408ca81054433150a4d2", + "lightweight_uploader": true + }, + "start_command": "start ota ap", + "master_off_control_supported": false, + "precondition": "OTA stopped; minimal group controls unavailable" + }, + { + "name": "minimal_start_ota_implicit_ap_after_stop_and_master_off_attempt", + "passed": true, + "ap_discovered": true, + "association": true, + "fresh_scan": { + "scan_status": 0, + "bssid_sha256": "1287bbb9c9aecd02ff94be170a225216154149c63384369ca9f9023f7a15623b", + "last_seen_ms": 0, + "frequency_mhz": 2412 + }, + "http": { + "status": 200, + "decompressed_bytes": 1078, + "body_sha256": "7379d4c0bcc34c9a277c7faac9d97735693ee2a2fc27408ca81054433150a4d2", + "lightweight_uploader": true + }, + "start_command": "start ota", + "master_off_control_supported": false, + "precondition": "OTA stopped; minimal group controls unavailable" + }, + { + "name": "minimal_uploader_ready_for_full_restore", + "passed": true, + "ap_discovered": true, + "association": true, + "fresh_scan": { + "scan_status": 0, + "bssid_sha256": "1287bbb9c9aecd02ff94be170a225216154149c63384369ca9f9023f7a15623b", + "last_seen_ms": 0, + "frequency_mhz": 2412 + }, + "http": { + "status": 200, + "decompressed_bytes": 1078, + "body_sha256": "7379d4c0bcc34c9a277c7faac9d97735693ee2a2fc27408ca81054433150a4d2", + "lightweight_uploader": true + } + }, + { + "name": "minimal_raw_uploader_installs_full_and_reboots", + "passed": true, + "upload_http_status": 200, + "running_image_hash_verified": true, + "application_slot_changed": true + } + ], + "ok": false, + "current_measured": false, + "full_restored": true, + "flashed": true, + "restore_sha256": "d534c9d79796886fb84690c784ae985bfef6d808d5e7879409f139e9463f126d", + "restore_image": { + "body_bytes": 2098688, + "image_bytes": 2098744, + "body_hash16": "8C8A7ADBDA25501E", + "target_id": 3885146193, + "hardware_id": "heltec_v4" + }, + "minimal_expected_body_hash16": "060106D2CA3CA669", + "minimal_before": { + "version": "v1.17.1.9-halo-keymind-cascade-dev-v4fix8-8d31b393 (Build: 06-Oct-2026)", + "running": { + "body_bytes": 1281520, + "image_bytes": 1281576, + "body_hash16": "060106D2CA3CA669" + }, + "inactive_slot": { + "address": 65536, + "size": 6553600 + } + }, + "common_preferences_preserved_on_minimal": true, + "compile_dependent_preference_keys": [ + "get espnow.enabled", + "get logging.output", + "get mqtt.enabled", + "get usb.logging", + "get webui", + "get wifi.powersave", + "get wifi.ssid" + ], + "start_uptime_secs": 161, + "end_minimal_uptime_secs": 173, + "no_cpu_reboot_during_minimal_cycles": true, + "restore_upload_attempted": true, + "restore_curl_status": 0, + "upload_http_status": 200, + "full_after": { + "version": "v1.17.1.9-halo-keymind-cascade-dev-v4fix8-8d31b393 (Build: 06-Oct-2026)", + "running": { + "body_bytes": 2098688, + "image_bytes": 2098744, + "body_hash16": "8C8A7ADBDA25501E" + }, + "inactive_slot": { + "address": 6619136, + "size": 6553600 + } + }, + "preferences_restored": true, + "identity_restored": true, + "acl_restored": true, + "runtime_restored": true, + "pi_wifi_restored": true, + "cleanup_errors": [ + "Full restore: AssertionError: fresh Full boot did not automatically start setup AP" + ] + }, + "fresh_boot": { + "source": "8d31b3932de614ac38d238f1a01010bc5f362fe8", + "expected_version": "v4fix8", + "expected_body_hash16": "8C8A7ADBDA25501E", + "browser_ready": false, + "browser_completed": false, + "browser_evaluated_externally": true, + "current_measured": false, + "flashed": false, + "ok": true, + "scan_diagnostics": [ + { + "channel": 0, + "start": -1, + "result": 4, + "elapsed_ms": 2399 + } + ], + "version": "v1.17.1.9-halo-keymind-cascade-dev-v4fix8-8d31b393 (Build: 06-Oct-2026)", + "image_hash_verified": true, + "baseline_saved_ssid_empty": true, + "runtime_baseline_off": true, + "start_uptime_secs": 178, + "reboot_reply_missing": true, + "ap_discovered": true, + "association": true, + "raw_target_visible": true, + "nm_target_visible": true, + "automatic_boot_ap": true, + "setup_api_verified": true, + "cached_scan_verified": true, + "cached_network_count": 3, + "preferences_restored": true, + "identity_restored": true, + "acl_restored": true, + "runtime_restored": true, + "version_restored": true, + "image_hash_restored": true, + "end_uptime_secs": 17, + "expected_cpu_reboot": true, + "pi_wifi_restored": true, + "temporary_profiles_removed": true, + "wired_route_restored": true, + "cleanup_errors": [] + }, + "minimal_assessment": { + "ap_and_upload_checks_passed": true, + "full_restored": true, + "preferences_restored": true, + "raw_runner_status": false, + "raw_runner_failure": "Automatic AP was asserted with the original WebUI master disabled; separate enabled fresh-boot test supplies the correct qualification.", + "original_webui_master_disabled": true + } + }, + "final_host": { + "serial_port_free": true, + "pi_wifi_original_profile_active": true, + "temporary_network_profiles_remaining": 0, + "services": { + "mctomqtt": "active", + "ModemManager": "active", + "mesh-logger": "inactive" + } + } +} diff --git a/platformio.ini b/platformio.ini index 3cc730c8d..2edfe5905 100644 --- a/platformio.ini +++ b/platformio.ini @@ -90,6 +90,7 @@ extra_scripts = pre:scripts/esp32_ota_heap_context.py pre:scripts/esp32_usb_session_fix.py pre:scripts/esp32_file_buffer_fix.py + pre:scripts/esp32_wifi_scan_fix.py pre:scripts/esp32_async_web_fix.py merge-bin.py post:tools/mota/pio_endf.py diff --git a/scripts/esp32_wifi_scan_fix.py b/scripts/esp32_wifi_scan_fix.py new file mode 100644 index 000000000..b740ba3cf --- /dev/null +++ b/scripts/esp32_wifi_scan_fix.py @@ -0,0 +1,92 @@ +"""Zero the pinned Arduino-ESP32 scan configuration in a private build copy. + +Arduino 2.0.17 leaves passive dwell and home-channel dwell uninitialized during +active scans. ESP-IDF can use passive channels in an otherwise active scan. +Preserve the caller's scan settings and let zero select the SDK defaults for +the unused fields. Never modify PlatformIO's shared framework package. +""" +import hashlib +from pathlib import Path +import re + + +PINNED_SCAN_SHA256 = "e96ac4be873860dc2b441d6d01d6613eb4d06bb21e3f225c3f428619496e6c21" +PATCHED_SCAN_SHA256 = "2ab9ce875ea8a21ee6635a5c95418185809d69a8e81d081b121c6463c0c89097" +SOURCE_SUFFIX = ("libraries", "WiFi", "src", "WiFiScan.cpp") +ORIGINAL_DECLARATION = "wifi_scan_config_t config;" +ZEROED_DECLARATION = "wifi_scan_config_t config = {};" +PATCHED_VERSION = (2, 0, 17) +# These existing recipes keep their own framework sources. This narrowly +# qualified Arduino 2 fix does not claim to qualify Arduino 3 scanning. +KNOWN_UNCHANGED_VERSIONS = {(3, 1, 3), (3, 3, 11)} + + +def patched_scan_source(source, framework_version=PATCHED_VERSION): + if framework_version != PATCHED_VERSION: + raise RuntimeError("ESP32 WiFi scan fix: unreviewed patch framework version") + source = source.replace("\r\n", "\n") + digest = hashlib.sha256(source.encode("utf-8")).hexdigest() + if digest == PATCHED_SCAN_SHA256: + return source + if digest != PINNED_SCAN_SHA256 or source.count(ORIGINAL_DECLARATION) != 1: + raise RuntimeError("ESP32 WiFi scan fix: changed pinned source; review SDK update") + patched = source.replace(ORIGINAL_DECLARATION, ZEROED_DECLARATION, 1) + if hashlib.sha256(patched.encode("utf-8")).hexdigest() != PATCHED_SCAN_SHA256: + raise RuntimeError("ESP32 WiFi scan fix: unexpected transform result") + return patched + + +def esp32_enabled(build_env): + defines = {} + for definition in build_env.get("CPPDEFINES", []): + if isinstance(definition, (tuple, list)): + defines[str(definition[0])] = str(definition[1]) + else: + defines[str(definition)] = "1" + return any(defines.get(name) not in (None, "0") + for name in ("ESP32_PLATFORM", "ESP32")) + + +def require_known_framework(source): + header_path = source.parents[3] / "cores" / "esp32" / "esp_arduino_version.h" + try: + header = header_path.read_text(encoding="utf-8") + except OSError as error: + raise RuntimeError("ESP32 WiFi scan fix: missing framework version header") from error + entries = re.findall(r"^#define ESP_ARDUINO_VERSION_(MAJOR|MINOR|PATCH)\s+(\d+)\s*$", + header, re.MULTILINE) + values = dict(entries) + if len(entries) != 3 or set(values) != {"MAJOR", "MINOR", "PATCH"}: + raise RuntimeError("ESP32 WiFi scan fix: changed/malformed framework version") + version = tuple(int(values[key]) for key in ("MAJOR", "MINOR", "PATCH")) + if version != PATCHED_VERSION and version not in KNOWN_UNCHANGED_VERSIONS: + raise RuntimeError("ESP32 WiFi scan fix: unreviewed framework version") + return version + + +def replace_scan_source(build_env, node): + if not esp32_enabled(build_env): + return node + source = Path(node.srcnode().get_abspath()) + if source.parts[-4:] != SOURCE_SUFFIX: + return node + version = require_known_framework(source) + if version in KNOWN_UNCHANGED_VERSIONS: + return node + patched = patched_scan_source(source.read_text(encoding="utf-8"), version) + destination = Path(build_env.subst("$BUILD_DIR")) / "patched-esp32-wifi" / source.name + destination.parent.mkdir(parents=True, exist_ok=True) + if not destination.exists() or destination.read_text(encoding="utf-8") != patched: + destination.write_text(patched, encoding="utf-8") + # The copied source includes the original WiFi library's private headers. + build_env.AppendUnique(CPPPATH=[str(source.parent)]) + return build_env.File(str(destination)) + + +def install(build_env): + build_env.AddBuildMiddleware(replace_scan_source, "*libraries*WiFi*src*WiFiScan.cpp") + + +if "Import" in globals(): + Import("env") + install(env) diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 5d0f6d427..6e3e621d8 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -15,6 +15,7 @@ static_assert(sizeof(WEBCONFIG_AP_PREFIX) <= 28, #include #include #include +#include #include #include @@ -783,6 +784,65 @@ bool WebConfigServer::getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t // Lifecycle // --------------------------------------------------------------------------- +static bool setupAccessPointStarted() { +#if ESP_ARDUINO_VERSION_MAJOR >= 3 + return WiFi.AP.started(); +#else + return (WiFi.getStatusBits() & AP_STARTED_BIT) != 0; +#endif +} + +static bool setupAccessPointReady(const char* ssid, int channel) { + wifi_mode_t mode = WIFI_MODE_NULL; + wifi_config_t config = {}; + uint8_t protocol = 0; + const size_t ssid_len = strlen(ssid); +#ifdef WEBCONFIG_AP_PASSWORD + const wifi_auth_mode_t auth = WEBCONFIG_AP_PASSWORD[0] + ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN; +#else + const wifi_auth_mode_t auth = WIFI_AUTH_OPEN; +#endif + return setupAccessPointStarted() + && esp_wifi_get_mode(&mode) == ESP_OK && (mode & WIFI_MODE_AP) + && esp_wifi_get_config(WIFI_IF_AP, &config) == ESP_OK + && config.ap.ssid_len == ssid_len + && memcmp(config.ap.ssid, ssid, ssid_len) == 0 + && config.ap.ssid_hidden == 0 && config.ap.authmode == auth + && config.ap.channel == channel + && esp_wifi_get_protocol(WIFI_IF_AP, &protocol) == ESP_OK + && protocol == mesh::wifi::kAccessPointProtocolMask + && static_cast(WiFi.softAPIP()) != 0 + && WiFi.status() != WL_CONNECTED; +} + +static bool scanSetupNetworks() { + // Populate the picker before the AP starts, while off-channel scanning + // cannot interrupt discovery or a client's first connection. + WiFi.scanDelete(); + const uint8_t channel = mesh::wifi::stationScanChannel(); + const int16_t scan_result = WiFi.scanNetworks(true, false, false, 200, channel); + const uint32_t scan_started = millis(); + while (WiFi.scanComplete() == WIFI_SCAN_RUNNING + && millis() - scan_started < 3600) delay(20); + mesh::usbDebugPort().printf( + "WebConfig pre-AP scan: channel=%u start=%d result=%d elapsed=%u\n", + (unsigned)channel, (int)scan_result, (int)WiFi.scanComplete(), + (unsigned)(millis() - scan_started)); + // Arduino's scanComplete() can clear its running flag on a facade timeout + // before the SDK scanner stops. Also cancel FAILED scans before enabling AP. + if (WiFi.scanComplete() < 0) { + if (esp_wifi_scan_stop() != ESP_OK) return false; + const uint32_t stop_started = millis(); + while (WiFi.scanComplete() == WIFI_SCAN_RUNNING + && millis() - stop_started < 100) delay(20); + WiFi.scanDelete(); + } + // A failed scan still allows manual SSID entry and a browser rescan. An + // active scanner must stop before we enable the AP interface. + return WiFi.scanComplete() != WIFI_SCAN_RUNNING; +} + bool WebConfigServer::startSetupMode(char reply[]) { if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) { strcpy(reply, "Error: WiFi disabled; use set wifi on or set 2.4ghz on"); @@ -797,10 +857,10 @@ bool WebConfigServer::startSetupMode(char reply[]) { _setup_reconnect_in_progress = false; _setup_reconnect_deadline = 0; _setup_started_at = 0; - // AP_STA (not pure AP) so the WiFi scan for the SSID picker works while - // the AP is up. STA stays unconnected - the bridge won't touch WiFi - // while wifi_ssid is empty, and `start webconfig ap` requires it stopped. - bool mode_ok = WiFi.mode(WIFI_AP_STA); + // Remove any previous setup/OTA AP before replacing its configuration. + // Keep STA and the driver alive so an ESP-NOW owner is not torn down. + // softAPConfig() adds AP to this STA interface for the SSID picker below. + bool mode_ok = WiFi.mode(WIFI_STA); // Setup mode has no login. Drop any STA association so the open setup API is // reachable only from the setup AP, not from the operator's LAN. WiFi.setAutoReconnect(false); @@ -808,14 +868,32 @@ bool WebConfigServer::startSetupMode(char reply[]) { delay(100); snprintf(_ap_ssid, sizeof(_ap_ssid), "%s-%02X%02X", WEBCONFIG_AP_PREFIX, _pub_key[0], _pub_key[1]); + const int ap_channel = mesh::wifi::accessPointChannel(); + const IPAddress ap_ip(192, 168, 4, 1); + const IPAddress ap_mask(255, 255, 255, 0); bool ap_ok = false; - for (uint8_t attempt = 1; attempt <= 6 && !ap_ok; ++attempt) { + bool scan_prepared = false; + const uint32_t startup_started = millis(); + // Keep failed startup below the console's five-second reply deadline even + // when both AP_STOP and AP_START are slow on every retry. + for (uint8_t attempt = 1; attempt <= 6 && !ap_ok + && millis() - startup_started < 3000; ++attempt) { + // AP_STOP is delivered asynchronously. Consume the previous start state + // before accepting the new AP_START, including on retries. + const uint32_t stop_started = millis(); + while (setupAccessPointStarted() && millis() - stop_started < 300) delay(20); + const bool station_ok = mode_ok && !setupAccessPointStarted() + && mesh::wifi::applyProtocolMask(WIFI_IF_STA) == ESP_OK; + if (!scan_prepared && station_ok) { + scan_prepared = scanSetupNetworks(); + if (!scan_prepared) break; + } + const bool configured = scan_prepared && station_ok + && WiFi.softAPConfig(ap_ip, ap_ip, ap_mask); #ifdef WEBCONFIG_AP_PASSWORD - ap_ok = WiFi.softAP(_ap_ssid, WEBCONFIG_AP_PASSWORD, - mesh::wifi::accessPointChannel()); + ap_ok = configured && WiFi.softAP(_ap_ssid, WEBCONFIG_AP_PASSWORD, ap_channel); #else - ap_ok = WiFi.softAP(_ap_ssid, nullptr, - mesh::wifi::accessPointChannel()); + ap_ok = configured && WiFi.softAP(_ap_ssid, nullptr, ap_channel); #endif if (ap_ok) { // ESP-NOW can leave the persistent AP protocol mask with the proprietary @@ -824,13 +902,15 @@ bool WebConfigServer::startSetupMode(char reply[]) { // advertise using the interoperable 2.4 GHz protocol set. const esp_err_t ap_protocol_result = mesh::wifi::applyAccessPointProtocolMask(); - const esp_err_t sta_protocol_result = esp_wifi_set_protocol( - WIFI_IF_STA, mesh::wifi::kProtocolMask); - if (ap_protocol_result == ESP_OK && sta_protocol_result == ESP_OK) break; - mesh::usbDebugPort().printf( - "WebConfig protocol reset failed: AP=%d STA=%d\n", - (int)ap_protocol_result, (int)sta_protocol_result); ap_ok = false; + if (ap_protocol_result == ESP_OK) { + const uint32_t start_started = millis(); + do { + ap_ok = setupAccessPointReady(_ap_ssid, ap_channel); + if (!ap_ok) delay(20); + } while (!ap_ok && millis() - start_started < 300); + } + if (ap_ok) break; } mesh::usbDebugPort().printf( @@ -840,7 +920,7 @@ bool WebConfigServer::startSetupMode(char reply[]) { (unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)); WiFi.softAPdisconnect(true); delay(250); - mode_ok = WiFi.mode(WIFI_AP_STA); + mode_ok = WiFi.mode(WIFI_STA); } if (!ap_ok) { if (!promote_lan) stopOwnedWiFiRadio(); @@ -861,10 +941,8 @@ bool WebConfigServer::startSetupMode(char reply[]) { _initial_setup = _wifi_ssid[0] == 0 && node.admin_password[0] != 0; _setup_started_at = millis(); _last_activity = _setup_started_at; - // A primary ESP-NOW radio cannot leave its selected channel while scanning. - // Ordinary WiFi targets retain the zero-channel all-band scan. - WiFi.scanNetworks(true, false, false, 300, - mesh::wifi::stationScanChannel()); + // Keep the pre-AP scan results for the picker. Only an explicit browser + // rescan needs to leave the AP's home channel after clients have joined. sprintf(reply, "WebConfig AP started: join '%s' then open http://%s/", _ap_ssid, ip.toString().c_str()); return true; @@ -2732,6 +2810,13 @@ void WebConfigServer::handleScan(AsyncWebServerRequest* req) { WiFi.scanDelete(); n = WIFI_SCAN_FAILED; } + if (_mode == MODE_SETUP && n == WIFI_SCAN_FAILED && !req->hasParam("rescan")) { + // A failed pre-AP scan still permits manual SSID entry. Leave the AP on + // its home channel until the operator explicitly requests another scan. + req->send(200, "application/json", + "{\"state\":\"done\",\"networks\":[],\"scan_failed\":true}"); + return; + } if (n == WIFI_SCAN_FAILED) { WiFi.scanNetworks(true, false, false, 300, mesh::wifi::stationScanChannel()); diff --git a/test/fixtures/esp32_wifi_scan/.gitattributes b/test/fixtures/esp32_wifi_scan/.gitattributes new file mode 100644 index 000000000..ac5dbfdea --- /dev/null +++ b/test/fixtures/esp32_wifi_scan/.gitattributes @@ -0,0 +1,2 @@ +# Preserve the byte-exact upstream source, including its original whitespace. +WiFiScan.cpp -whitespace diff --git a/test/fixtures/esp32_wifi_scan/README.txt b/test/fixtures/esp32_wifi_scan/README.txt new file mode 100644 index 000000000..f706dcf14 --- /dev/null +++ b/test/fixtures/esp32_wifi_scan/README.txt @@ -0,0 +1,22 @@ +Pinned source witnesses for the build-local Arduino-ESP32 scan fix. + +WiFiScan.cpp is the complete, byte-exact Arduino-ESP32 2.0.17 source, preserving +its upstream copyright and LGPL notice: +https://github.com/espressif/arduino-esp32/blob/2.0.17/libraries/WiFi/src/WiFiScan.cpp +SHA256 e96ac4be873860dc2b441d6d01d6613eb4d06bb21e3f225c3f428619496e6c21 +The local .gitattributes preserves upstream whitespace for this hashed witness. + +wifi_scan_config.h retains the original license notice and the exact scan type +and configuration declarations from the pinned ESP-IDF 4.4.7 ESP32-S3 header: +https://github.com/espressif/esp-idf/blob/v4.4.7/components/esp_wifi/include/esp_wifi_types.h +This is a struct containing both active and passive dwell fields, not a union. + +The test extracts and executes the actual scanNetworks method, capturing the +configuration at its esp_wifi_scan_start boundary. Where supported, compiler +automatic-variable poisoning makes the prior uninitialized fields deterministic. +Older host compilers use explicit poison only for the unsafe negative control. +The patched method is always executed unchanged. This proves initialization and +preservation of scan parameters, not radio timing or physical AP discovery. + +Arduino 3.1.3 and 3.3.11 are explicitly unchanged to preserve existing recipes. +Their scan implementation and hardware behavior are outside this fix's scope. diff --git a/test/fixtures/esp32_wifi_scan/WiFiScan.cpp b/test/fixtures/esp32_wifi_scan/WiFiScan.cpp new file mode 100644 index 000000000..f4a32e49a --- /dev/null +++ b/test/fixtures/esp32_wifi_scan/WiFiScan.cpp @@ -0,0 +1,281 @@ +/* + ESP8266WiFiScan.cpp - WiFi library for esp8266 + + Copyright (c) 2014 Ivan Grokhotkov. All rights reserved. + This file is part of the esp8266 core for Arduino environment. + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Lesser General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + + Reworked on 28 Dec 2015 by Markus Sattler + + */ + + +#include "WiFi.h" +#include "WiFiGeneric.h" +#include "WiFiScan.h" + +extern "C" { +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "lwip/err.h" +} + +bool WiFiScanClass::_scanAsync = false; +uint32_t WiFiScanClass::_scanStarted = 0; +uint32_t WiFiScanClass::_scanTimeout = 10000; +uint16_t WiFiScanClass::_scanCount = 0; +void* WiFiScanClass::_scanResult = 0; + +/** + * Start scan WiFi networks available + * @param async run in async mode + * @param show_hidden show hidden networks + * @return Number of discovered networks + */ +int16_t WiFiScanClass::scanNetworks(bool async, bool show_hidden, bool passive, uint32_t max_ms_per_chan, uint8_t channel, const char * ssid, const uint8_t * bssid) +{ + if(WiFiGenericClass::getStatusBits() & WIFI_SCANNING_BIT) { + return WIFI_SCAN_RUNNING; + } + + WiFiScanClass::_scanTimeout = max_ms_per_chan * 20; + WiFiScanClass::_scanAsync = async; + + WiFi.enableSTA(true); + + scanDelete(); + + wifi_scan_config_t config; + config.ssid = (uint8_t*)ssid; + config.bssid = (uint8_t*)bssid; + config.channel = channel; + config.show_hidden = show_hidden; + if(passive){ + config.scan_type = WIFI_SCAN_TYPE_PASSIVE; + config.scan_time.passive = max_ms_per_chan; + } else { + config.scan_type = WIFI_SCAN_TYPE_ACTIVE; + config.scan_time.active.min = 100; + config.scan_time.active.max = max_ms_per_chan; + } + if(esp_wifi_scan_start(&config, false) == ESP_OK) { + _scanStarted = millis(); + if (!_scanStarted) { //Prevent 0 from millis overflow + ++_scanStarted; + } + + WiFiGenericClass::clearStatusBits(WIFI_SCAN_DONE_BIT); + WiFiGenericClass::setStatusBits(WIFI_SCANNING_BIT); + + if(WiFiScanClass::_scanAsync) { + return WIFI_SCAN_RUNNING; + } + if(WiFiGenericClass::waitStatusBits(WIFI_SCAN_DONE_BIT, 10000)){ + return (int16_t) WiFiScanClass::_scanCount; + } + } + return WIFI_SCAN_FAILED; +} + + +/** + * private + * scan callback + * @param result void *arg + * @param status STATUS + */ +void WiFiScanClass::_scanDone() +{ + esp_wifi_scan_get_ap_num(&(WiFiScanClass::_scanCount)); + if(WiFiScanClass::_scanCount) { + WiFiScanClass::_scanResult = new wifi_ap_record_t[WiFiScanClass::_scanCount]; + if(!WiFiScanClass::_scanResult || esp_wifi_scan_get_ap_records(&(WiFiScanClass::_scanCount), (wifi_ap_record_t*)_scanResult) != ESP_OK) { + WiFiScanClass::_scanCount = 0; + } + } + WiFiScanClass::_scanStarted=0; //Reset after a scan is completed for normal behavior + WiFiGenericClass::setStatusBits(WIFI_SCAN_DONE_BIT); + WiFiGenericClass::clearStatusBits(WIFI_SCANNING_BIT); +} + +/** + * + * @param i specify from which network item want to get the information + * @return bss_info * + */ +void * WiFiScanClass::_getScanInfoByIndex(int i) +{ + if(!WiFiScanClass::_scanResult || (size_t) i >= WiFiScanClass::_scanCount) { + return 0; + } + return reinterpret_cast(WiFiScanClass::_scanResult) + i; +} + +/** + * called to get the scan state in Async mode + * @return scan result or status + * -1 if scan not fin + * -2 if scan not triggered + */ +int16_t WiFiScanClass::scanComplete() +{ + if (WiFiScanClass::_scanStarted && (millis()-WiFiScanClass::_scanStarted) > WiFiScanClass::_scanTimeout) { //Check is scan was started and if the delay expired, return WIFI_SCAN_FAILED in this case + WiFiGenericClass::clearStatusBits(WIFI_SCANNING_BIT); + return WIFI_SCAN_FAILED; + } + + if(WiFiGenericClass::getStatusBits() & WIFI_SCAN_DONE_BIT) { + return WiFiScanClass::_scanCount; + } + + if(WiFiGenericClass::getStatusBits() & WIFI_SCANNING_BIT) { + return WIFI_SCAN_RUNNING; + } + + return WIFI_SCAN_FAILED; +} + +/** + * delete last scan result from RAM + */ +void WiFiScanClass::scanDelete() +{ + WiFiGenericClass::clearStatusBits(WIFI_SCAN_DONE_BIT); + if(WiFiScanClass::_scanResult) { + delete[] reinterpret_cast(WiFiScanClass::_scanResult); + WiFiScanClass::_scanResult = 0; + WiFiScanClass::_scanCount = 0; + } +} + + +/** + * loads all infos from a scanned wifi in to the ptr parameters + * @param networkItem uint8_t + * @param ssid const char** + * @param encryptionType uint8_t * + * @param RSSI int32_t * + * @param BSSID uint8_t ** + * @param channel int32_t * + * @return (true if ok) + */ +bool WiFiScanClass::getNetworkInfo(uint8_t i, String &ssid, uint8_t &encType, int32_t &rssi, uint8_t* &bssid, int32_t &channel) +{ + wifi_ap_record_t* it = reinterpret_cast(_getScanInfoByIndex(i)); + if(!it) { + return false; + } + ssid = (const char*) it->ssid; + encType = it->authmode; + rssi = it->rssi; + bssid = it->bssid; + channel = it->primary; + return true; +} + + +/** + * Return the SSID discovered during the network scan. + * @param i specify from which network item want to get the information + * @return ssid string of the specified item on the networks scanned list + */ +String WiFiScanClass::SSID(uint8_t i) +{ + wifi_ap_record_t* it = reinterpret_cast(_getScanInfoByIndex(i)); + if(!it) { + return String(); + } + return String(reinterpret_cast(it->ssid)); +} + + +/** + * Return the encryption type of the networks discovered during the scanNetworks + * @param i specify from which network item want to get the information + * @return encryption type (enum wl_enc_type) of the specified item on the networks scanned list + */ +wifi_auth_mode_t WiFiScanClass::encryptionType(uint8_t i) +{ + wifi_ap_record_t* it = reinterpret_cast(_getScanInfoByIndex(i)); + if(!it) { + return WIFI_AUTH_OPEN; + } + return it->authmode; +} + +/** + * Return the RSSI of the networks discovered during the scanNetworks + * @param i specify from which network item want to get the information + * @return signed value of RSSI of the specified item on the networks scanned list + */ +int32_t WiFiScanClass::RSSI(uint8_t i) +{ + wifi_ap_record_t* it = reinterpret_cast(_getScanInfoByIndex(i)); + if(!it) { + return 0; + } + return it->rssi; +} + + +/** + * return MAC / BSSID of scanned wifi + * @param i specify from which network item want to get the information + * @return uint8_t * MAC / BSSID of scanned wifi + */ +uint8_t * WiFiScanClass::BSSID(uint8_t i) +{ + wifi_ap_record_t* it = reinterpret_cast(_getScanInfoByIndex(i)); + if(!it) { + return 0; + } + return it->bssid; +} + +/** + * return MAC / BSSID of scanned wifi + * @param i specify from which network item want to get the information + * @return String MAC / BSSID of scanned wifi + */ +String WiFiScanClass::BSSIDstr(uint8_t i) +{ + char mac[18] = { 0 }; + wifi_ap_record_t* it = reinterpret_cast(_getScanInfoByIndex(i)); + if(!it) { + return String(); + } + sprintf(mac, "%02X:%02X:%02X:%02X:%02X:%02X", it->bssid[0], it->bssid[1], it->bssid[2], it->bssid[3], it->bssid[4], it->bssid[5]); + return String(mac); +} + +int32_t WiFiScanClass::channel(uint8_t i) +{ + wifi_ap_record_t* it = reinterpret_cast(_getScanInfoByIndex(i)); + if(!it) { + return 0; + } + return it->primary; +} + diff --git a/test/fixtures/esp32_wifi_scan/wifi_scan_config.h b/test/fixtures/esp32_wifi_scan/wifi_scan_config.h new file mode 100644 index 000000000..7c1e0caa6 --- /dev/null +++ b/test/fixtures/esp32_wifi_scan/wifi_scan_config.h @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2015-2023 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Apache-2.0 + */ + + +#pragma once +#include +#include + +typedef enum { + WIFI_SCAN_TYPE_ACTIVE = 0, /**< active scan */ + WIFI_SCAN_TYPE_PASSIVE, /**< passive scan */ +} wifi_scan_type_t; + +/** @brief Range of active scan times per channel */ +typedef struct { + uint32_t min; /**< minimum active scan time per channel, units: millisecond */ + uint32_t max; /**< maximum active scan time per channel, units: millisecond, values above 1500ms may + cause station to disconnect from AP and are not recommended. */ +} wifi_active_scan_time_t; + +/** @brief Aggregate of active & passive scan time per channel */ +typedef struct { + wifi_active_scan_time_t active; /**< active scan time per channel, units: millisecond. */ + uint32_t passive; /**< passive scan time per channel, units: millisecond, values above 1500ms may + cause station to disconnect from AP and are not recommended. */ +} wifi_scan_time_t; + +/** @brief Parameters for an SSID scan. */ +typedef struct { + uint8_t *ssid; /**< SSID of AP */ + uint8_t *bssid; /**< MAC address of AP */ + uint8_t channel; /**< channel, scan the specific channel */ + bool show_hidden; /**< enable to scan AP whose SSID is hidden */ + wifi_scan_type_t scan_type; /**< scan type, active or passive */ + wifi_scan_time_t scan_time; /**< scan time per channel */ + uint8_t home_chan_dwell_time;/**< time spent at home channel between scanning consecutive channels.*/ +} wifi_scan_config_t; diff --git a/test/fixtures/webconfig_ap_start.cpp b/test/fixtures/webconfig_ap_start.cpp new file mode 100644 index 000000000..cc74b0c17 --- /dev/null +++ b/test/fixtures/webconfig_ap_start.cpp @@ -0,0 +1,600 @@ +// Only peripheral endpoints are doubles. The readiness helper and startup +// method are extracted verbatim from production WebConfigServer.cpp. +#include +#include +#include +#include +#include +#include +#include + +using esp_err_t = int; +using wifi_mode_t = int; +using wifi_auth_mode_t = int; +constexpr esp_err_t ESP_OK = 0; +constexpr int WIFI_AUTH_OPEN = 0, WIFI_AUTH_WPA2_PSK = 3; +constexpr int WL_CONNECTED = 3, WL_DISCONNECTED = 6; +constexpr int WIFI_SCAN_FAILED = -2, WIFI_SCAN_RUNNING = -1; +constexpr int WIFI_OFF = 0, WIFI_STA = 1, WIFI_AP = 2, WIFI_AP_STA = 3; +constexpr int WIFI_MODE_NULL = WIFI_OFF, WIFI_MODE_STA = WIFI_STA; +constexpr int WIFI_MODE_AP = WIFI_AP, WIFI_MODE_APSTA = WIFI_AP_STA; +constexpr int WIFI_IF_STA = 0, WIFI_IF_AP = 1; +constexpr uint8_t WIFI_PROTOCOL_11B = 1, WIFI_PROTOCOL_11G = 2; +constexpr uint8_t WIFI_PROTOCOL_11N = 4, WIFI_PROTOCOL_LR = 8; +constexpr int WIFI_AP_STARTED_BIT = 1 << 0; +constexpr int AP_STARTED_BIT = WIFI_AP_STARTED_BIT; +constexpr int MALLOC_CAP_INTERNAL = 1; +#define WEBCONFIG_AP_PREFIX "MC" +#ifndef ESP_ARDUINO_VERSION_MAJOR +#define ESP_ARDUINO_VERSION_MAJOR 2 +#endif + +struct IPAddress { + uint32_t value = 0; + IPAddress() = default; + IPAddress(uint8_t a, uint8_t b, uint8_t c, uint8_t d) + : value((uint32_t(a) << 24) | (uint32_t(b) << 16) | (uint32_t(c) << 8) | d) {} + explicit operator uint32_t() const { return value; } + std::string toString() const { + char text[16]; + snprintf(text, sizeof(text), "%u.%u.%u.%u", (value >> 24) & 255, + (value >> 16) & 255, (value >> 8) & 255, value & 255); + return text; + } +}; +struct wifi_config_t { + struct { + uint8_t ssid[32] = {}; + uint8_t ssid_len = 0, ssid_hidden = 0, channel = 1; + wifi_auth_mode_t authmode = WIFI_AUTH_OPEN; + } ap; +}; +enum class Fault { + None, StaleSsid, HiddenSsid, WrongChannel, WrongProtocol, MissingMode, + NotStarted, ZeroIp, DelayedStart, StaleOnce, SetterFailure, WrongAuth, StillConnected, + ModeSetterFailure, IpConfigSetterFailure, StaProtocolSetterFailure, + SdkModeReadFailure, SdkConfigReadFailure, SdkProtocolReadFailure +}; +enum class ScanBehavior { + Complete, SlowComplete, Failure, Stuck, StopDelayed, Unstoppable, + FacadeFailedActive, FacadeFailedStopRejected +}; +constexpr wifi_auth_mode_t expectedAuth() { +#ifdef WEBCONFIG_AP_PASSWORD + return WEBCONFIG_AP_PASSWORD[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN; +#else + return WIFI_AUTH_OPEN; +#endif +} +void delay(unsigned amount); +struct WiFiFake { + bool initialized = true, shared_espnow = false, auto_reconnect = true; + bool configured_ip = false, ap_started = false; + bool delayed_stop = false; + unsigned pending_stop_ms = 0; + unsigned stop_delay_ms = 160, config_delay_ms = 0; + struct APStatus { bool started() const; } AP; + int live_mode = WIFI_STA; + uint8_t live_protocol = WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N; + wifi_config_t live_config; + IPAddress ap_ip{192, 168, 4, 1}, planned_ip{192, 168, 4, 1}; + Fault fault = Fault::None; + ScanBehavior scan_behavior = ScanBehavior::Complete; + unsigned ap_calls = 0, config_calls = 0, sta_only_calls = 0; + unsigned ap_stops = 0, driver_stops = 0, driver_deinits = 0; + unsigned polls = 0, scans = 0; + unsigned scan_deletes = 0, scan_queries = 0; + unsigned sdk_scan_stops = 0, scan_remaining_ms = 0; + unsigned stale_picker_results = 2; + int scan_result = 2; + bool departed_home_channel = false, scan_stop_pending = false, sdk_scan_active = false; + unsigned scans_during_ap = 0; + bool mode(int mode) { + assert(initialized); + if (fault == Fault::ModeSetterFailure) return false; + if (mode == WIFI_STA) ++sta_only_calls; + const bool removing_ap = (live_mode & WIFI_AP) && !(mode & WIFI_AP); + live_mode = mode; + if (!(mode & WIFI_AP)) { + if (delayed_stop && ap_started) { + if (removing_ap) pending_stop_ms = stop_delay_ms; + } + else ap_started = false; + } + return true; + } + int getMode() const { return live_mode; } + int status() const { return fault == Fault::StillConnected ? WL_CONNECTED : WL_DISCONNECTED; } + bool setAutoReconnect(bool enabled) { auto_reconnect = enabled; return true; } + bool disconnect(bool wifioff, bool erase) { + assert(!wifioff && erase); return true; + } + bool softAPConfig(IPAddress ip, IPAddress gateway, IPAddress mask) { + ++config_calls; + assert(ip.value == IPAddress(192, 168, 4, 1).value && gateway.value == ip.value); + assert(mask.value == IPAddress(255, 255, 255, 0).value); + if (fault == Fault::IpConfigSetterFailure) return false; + assert(scan_result != WIFI_SCAN_RUNNING && !sdk_scan_active); + if (config_delay_ms) delay(config_delay_ms); + configured_ip = true; planned_ip = ip; + live_mode |= WIFI_AP; // Arduino's configuration call enables the AP. + return true; + } + bool softAP(const char* ssid, const char* password, int channel) { +#ifdef WEBCONFIG_AP_PASSWORD + assert(password != nullptr && strcmp(password, WEBCONFIG_AP_PASSWORD) == 0); +#else + assert(password == nullptr); +#endif + assert(channel == 6); + ++ap_calls; polls = 0; + if (fault == Fault::SetterFailure) return false; + assert(pending_stop_ms == 0); // A new start must not inherit old AP_START. + live_mode |= WIFI_AP; + live_config = {}; + const bool stale = fault == Fault::StaleSsid || (fault == Fault::StaleOnce && ap_calls == 1); + const char* actual = stale ? "MeshCore-OTA" : ssid; + live_config.ap.ssid_len = uint8_t(strlen(actual)); + memcpy(live_config.ap.ssid, actual, strlen(actual)); + live_config.ap.ssid_hidden = fault == Fault::HiddenSsid ? 1 : 0; + live_config.ap.channel = fault == Fault::WrongChannel ? 11 : uint8_t(channel); + live_config.ap.authmode = fault == Fault::WrongAuth + ? (expectedAuth() == WIFI_AUTH_OPEN ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN) + : expectedAuth(); + ap_ip = fault == Fault::ZeroIp ? IPAddress() : planned_ip; + ap_started = fault != Fault::NotStarted && fault != Fault::DelayedStart; + // Every injected state error deliberately returns success here. + return true; + } + bool softAPdisconnect(bool off) { + assert(off); ++ap_stops; + live_mode &= ~WIFI_AP; + if (delayed_stop && ap_started) pending_stop_ms = stop_delay_ms; + else ap_started = false; + return true; + } + IPAddress softAPIP() const { return ap_ip; } + int getStatusBits() const { return ap_started ? WIFI_AP_STARTED_BIT : 0; } + int scanNetworks(bool async, bool hidden, bool passive, unsigned maximum, uint8_t channel) { + assert(async && !hidden && !passive && (maximum == 200 || maximum == 300)); + assert(channel == (shared_espnow ? 6 : 0)); + if (live_mode & WIFI_AP) ++scans_during_ap; + if (maximum == 200) assert(live_mode == WIFI_STA && !ap_started); + ++scans; scan_result = WIFI_SCAN_RUNNING; sdk_scan_active = true; + if (channel == 0) departed_home_channel = true; + if (scan_behavior == ScanBehavior::Failure) { + scan_result = WIFI_SCAN_FAILED; + sdk_scan_active = false; + return WIFI_SCAN_FAILED; + } + if (scan_behavior == ScanBehavior::Complete) scan_remaining_ms = 100; + else if (scan_behavior == ScanBehavior::SlowComplete) scan_remaining_ms = 3500; + else if (scan_behavior == ScanBehavior::FacadeFailedActive + || scan_behavior == ScanBehavior::FacadeFailedStopRejected) + scan_result = WIFI_SCAN_FAILED; // Facade timeout hides the live SDK scanner. + return WIFI_SCAN_RUNNING; + } + void scanDelete() { + ++scan_deletes; stale_picker_results = 0; + // Arduino deletes result storage; it does not cancel an SDK scan. + if (!sdk_scan_active) scan_result = WIFI_SCAN_FAILED; + } + int scanComplete() { ++scan_queries; return scan_result; } + const char* SSID(int) const { return "cached-network"; } + int RSSI(int) const { return -50; } + int encryptionType(int) const { return WIFI_AUTH_OPEN; } + int channel(int) const { return 6; } +} WiFi; +bool WiFiFake::APStatus::started() const { return WiFi.ap_started; } + +static uint32_t clock_ms = 100; +uint32_t millis() { return clock_ms; } +void delay(unsigned amount) { + clock_ms += amount; + if (WiFi.pending_stop_ms) { + WiFi.pending_stop_ms -= std::min(amount, WiFi.pending_stop_ms); + if (!WiFi.pending_stop_ms) WiFi.ap_started = false; + } + if (amount == 20 && ++WiFi.polls >= 3 && WiFi.fault == Fault::DelayedStart + && (WiFi.live_mode & WIFI_AP)) + WiFi.ap_started = true; + if (WiFi.scan_remaining_ms) { + WiFi.scan_remaining_ms -= std::min(amount, WiFi.scan_remaining_ms); + if (!WiFi.scan_remaining_ms) { + WiFi.scan_result = WiFi.scan_stop_pending ? WIFI_SCAN_FAILED : 2; + WiFi.sdk_scan_active = false; + WiFi.scan_stop_pending = false; + } + } +} +int esp_wifi_scan_stop() { + ++WiFi.sdk_scan_stops; + if (WiFi.scan_behavior == ScanBehavior::Unstoppable + || WiFi.scan_behavior == ScanBehavior::FacadeFailedStopRejected) return -1; + if (WiFi.scan_behavior == ScanBehavior::StopDelayed) { + WiFi.scan_stop_pending = true; + WiFi.scan_remaining_ms = 60; + } else { + WiFi.scan_result = WIFI_SCAN_FAILED; WiFi.sdk_scan_active = false; + } + return ESP_OK; +} +esp_err_t esp_wifi_get_mode(wifi_mode_t* mode) { + if (WiFi.fault == Fault::SdkModeReadFailure) return -1; + *mode = WiFi.fault == Fault::MissingMode ? WIFI_STA : WiFi.live_mode; + return ESP_OK; +} +esp_err_t esp_wifi_get_config(int interface, wifi_config_t* config) { + assert(interface == WIFI_IF_AP); + if (WiFi.fault == Fault::SdkConfigReadFailure) return -1; + *config = WiFi.live_config; return ESP_OK; +} +esp_err_t esp_wifi_get_protocol(int interface, uint8_t* protocol) { + assert(interface == WIFI_IF_AP); + if (WiFi.fault == Fault::SdkProtocolReadFailure) return -1; + *protocol = WiFi.fault == Fault::WrongProtocol ? WIFI_PROTOCOL_LR : WiFi.live_protocol; + return ESP_OK; +} +esp_err_t esp_wifi_set_protocol(int interface, uint8_t protocol) { + if (interface == WIFI_IF_AP) WiFi.live_protocol = protocol; + else { + assert(interface == WIFI_IF_STA); + if (WiFi.fault == Fault::StaProtocolSetterFailure) return -1; + } + return ESP_OK; +} +int esp_wifi_stop() { ++WiFi.driver_stops; return ESP_OK; } +int esp_wifi_deinit() { ++WiFi.driver_deinits; WiFi.initialized = false; return ESP_OK; } +unsigned heap_caps_get_largest_free_block(int) { return 100000; } +struct { unsigned getFreeHeap() const { return 100000; } } ESP; + +namespace mesh { +struct DebugPort { template void printf(const char*, Args...) {} }; +DebugPort& usbDebugPort() { static DebugPort port; return port; } +namespace wireless { +constexpr uint8_t WiFi = 1; +struct Control { bool disabled = false; bool blocked(uint8_t) const { return disabled; } }; +Control& control() { static Control control; return control; } +} +namespace wifi { +@AP_MASK@ +constexpr uint8_t kProtocolMask = WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N | WIFI_PROTOCOL_LR; +int accessPointChannel() { return 6; } +uint8_t stationScanChannel() { return WiFi.shared_espnow ? 6 : 0; } +esp_err_t applyAccessPointProtocolMask() { return esp_wifi_set_protocol(WIFI_IF_AP, kAccessPointProtocolMask); } +esp_err_t applyProtocolMask(int interface) { return esp_wifi_set_protocol(interface, kProtocolMask); } +} +} + +void stopOwnedWiFiRadio() { + if (WiFi.shared_espnow) WiFi.mode(WIFI_STA); + else WiFi.live_mode = WIFI_OFF; +} +struct DNSServer { + static unsigned starts; + void start(int port, const char* wildcard, IPAddress ip) { + assert(port == 53 && strcmp(wildcard, "*") == 0 && ip.value != 0); ++starts; + } +}; +unsigned DNSServer::starts = 0; +struct NodeSnapshot { char admin_password[16] = {}; }; +struct Callbacks { void getNodeSnapshot(NodeSnapshot& node) { strcpy(node.admin_password, "configured"); } }; +struct AsyncResponseStream { std::string state; unsigned networks = 0; }; +struct AsyncWebServerRequest { + int status = 0; + bool rescan = false; + std::string payload; + unsigned completed_networks = 0; + AsyncResponseStream response; + bool hasParam(const char* name) const { return rescan && strcmp(name, "rescan") == 0; } + void send(int value) { status = value; } + void send(int value, const char* type, const char* body) { + assert(strcmp(type, "application/json") == 0); status = value; payload = body; + } + AsyncResponseStream* beginResponseStream(const char*) { return &response; } + void send(AsyncResponseStream* value) { + assert(value == &response); status = 200; + payload = "{\"state\":\"" + value->state + "\"}"; + completed_networks = value->networks; + } +}; +// Serialization is an endpoint double; tests below exercise the complete +// production scan-request control flow rather than a copied branch. +struct JsonValue { + std::string* text = nullptr; + void operator=(const char* value) { if (text) *text = value; } + template void operator=(const T&) {} +}; +struct JsonObject { JsonValue operator[](const char*) { return {}; } }; +struct JsonArray { + unsigned* count; + JsonObject createNestedObject() { ++*count; return {}; } +}; +struct DynamicJsonDocument { + std::string state; + unsigned networks = 0; + explicit DynamicJsonDocument(unsigned) {} + JsonValue operator[](const char*) { return {&state}; } + JsonArray createNestedArray(const char*) { return {&networks}; } +}; +void serializeJson(DynamicJsonDocument& doc, AsyncResponseStream& response) { + response.state = doc.state; response.networks = doc.networks; +} +struct WebConfigServer { + enum Mode { MODE_OFF, MODE_LAN, MODE_SETUP }; + Mode _mode = MODE_OFF; + bool _owns_wifi = true, _stopping = false; + bool _retry_saved_wifi_in_setup = true, _setup_reconnect_in_progress = true; + bool _was_setup_ap = false, _initial_setup = false; + uint32_t _setup_reconnect_deadline = 5, _setup_started_at = 5, _last_activity = 0; + const uint8_t _pub_key[2] = {0x62, 0xF2}; + char _ap_ssid[33] = {}, _wifi_ssid[32] = {}; + DNSServer* _dns = nullptr; + Callbacks callbacks; + Callbacks* _cb = &callbacks; + unsigned servers = 0; + bool auth_allowed = true; + ~WebConfigServer() { delete _dns; } + void createServer() { ++servers; } + bool startSetupMode(char reply[]); + bool checkAuth(AsyncWebServerRequest*) const { return auth_allowed; } + void handleScan(AsyncWebServerRequest* req); +}; +@METHODS@ + +void requireEndpoint(const WebConfigServer& portal, const char* reply, bool cached_scan = true) { + assert(portal._mode == WebConfigServer::MODE_SETUP && portal._dns != nullptr); + assert(WiFi.live_mode == WIFI_AP_STA && WiFi.ap_started); + assert(WiFi.live_config.ap.ssid_len == 7); + assert(memcmp(WiFi.live_config.ap.ssid, "MC-62F2", 7) == 0); + assert(!WiFi.live_config.ap.ssid_hidden && WiFi.live_config.ap.channel == 6); + assert(WiFi.live_config.ap.authmode == expectedAuth()); + assert(WiFi.live_protocol == mesh::wifi::kAccessPointProtocolMask); + assert(WiFi.ap_ip.value != 0 && WiFi.config_calls > 0 && WiFi.sta_only_calls > 0); + assert(WiFi.scans == 1 && WiFi.scans_during_ap == 0 && DNSServer::starts == 1); + const bool stopped_scan = WiFi.scan_behavior != ScanBehavior::Complete + && WiFi.scan_behavior != ScanBehavior::SlowComplete; + assert(WiFi.scan_deletes == (stopped_scan ? 2u : 1u) && WiFi.stale_picker_results == 0); + assert(WiFi.scan_result == (cached_scan ? 2 : WIFI_SCAN_FAILED)); + assert(!WiFi.sdk_scan_active && WiFi.sdk_scan_stops == (stopped_scan ? 1u : 0u)); + assert(!WiFi.auto_reconnect && !portal._retry_saved_wifi_in_setup); + assert(strstr(reply, "join 'MC-62F2' then open http://192.168.4.1/")); + assert(portal.servers == 1); + assert(WiFi.initialized && WiFi.driver_stops == 0 && WiFi.driver_deinits == 0); +} +void reset(Fault fault = Fault::None) { + WiFi = WiFiFake{}; WiFi.fault = fault; + clock_ms = 100; + DNSServer::starts = 0; mesh::wireless::control().disabled = false; +} +int main() { + for (Fault fault : {Fault::StaleSsid, Fault::HiddenSsid, Fault::WrongChannel, + Fault::WrongProtocol, Fault::MissingMode, Fault::NotStarted, + Fault::ZeroIp, Fault::SetterFailure, Fault::WrongAuth, Fault::StillConnected, + Fault::ModeSetterFailure, Fault::IpConfigSetterFailure, + Fault::StaProtocolSetterFailure, Fault::SdkModeReadFailure, + Fault::SdkConfigReadFailure, Fault::SdkProtocolReadFailure}) { + reset(fault); WiFi.shared_espnow = true; + WebConfigServer portal; + char reply[160] = {}; + const uint32_t started = millis(); + assert(!portal.startSetupMode(reply)); + assert(millis() - started < 4500); + assert(portal._mode == WebConfigServer::MODE_OFF && !portal._dns && portal.servers == 0); + const unsigned prepared = fault != Fault::ModeSetterFailure + && fault != Fault::StaProtocolSetterFailure ? 1u : 0u; + assert(!strstr(reply, "AP started") && WiFi.scans == prepared + && WiFi.scan_deletes == prepared && WiFi.scans_during_ap == 0 && DNSServer::starts == 0); + assert(WiFi.ap_calls <= 6 && WiFi.initialized && (WiFi.live_mode & WIFI_STA)); + assert(WiFi.driver_stops == 0 && WiFi.driver_deinits == 0); + } + // Slow asynchronous stops and successful-but-stale setters must remain a + // bounded CLI request, rather than exhausting every per-attempt timeout. + reset(Fault::StaleSsid); WiFi.shared_espnow = true; + WiFi.delayed_stop = true; WiFi.stop_delay_ms = 280; WiFi.config_delay_ms = 100; + WiFi.live_mode = WIFI_AP_STA; WiFi.ap_started = true; + WebConfigServer slow_failure; + char slow_reply[160] = {}; + const uint32_t slow_started = millis(); + assert(!slow_failure.startSetupMode(slow_reply)); + assert(millis() - slow_started < 4500); + assert(WiFi.ap_calls < 6 && !slow_failure._dns && slow_failure.servers == 0); + assert(WiFi.initialized && (WiFi.live_mode & WIFI_STA)); + assert(!strstr(slow_reply, "AP started")); + for (bool shared_espnow : {false, true}) { + for (Fault fault : {Fault::None, Fault::DelayedStart, Fault::StaleOnce}) { + reset(fault); WiFi.shared_espnow = shared_espnow; + WebConfigServer portal; + char reply[160] = {}; + assert(portal.startSetupMode(reply)); + requireEndpoint(portal, reply); + if (fault == Fault::StaleOnce) assert(WiFi.ap_calls >= 2); + if (fault == Fault::DelayedStart) assert(WiFi.polls >= 3); + } + } + reset(); WiFi.scan_behavior = ScanBehavior::SlowComplete; + WebConfigServer slow_success; char slow_success_reply[160] = {}; + const uint32_t long_scan_started = millis(); + assert(slow_success.startSetupMode(slow_success_reply)); + assert(millis() - long_scan_started < 4500); + requireEndpoint(slow_success, slow_success_reply); + // Completing just before the pre-scan deadline must still leave enough + // time to bring up a delayed AP after an asynchronous old AP shutdown. + reset(Fault::DelayedStart); WiFi.scan_behavior = ScanBehavior::SlowComplete; + WiFi.delayed_stop = true; WiFi.stop_delay_ms = 280; WiFi.config_delay_ms = 100; + WiFi.live_mode = WIFI_AP_STA; WiFi.ap_started = true; + WebConfigServer slow_handoff; char slow_handoff_reply[160] = {}; + const uint32_t slow_handoff_started = millis(); + assert(slow_handoff.startSetupMode(slow_handoff_reply)); + assert(millis() - slow_handoff_started < 4500); + requireEndpoint(slow_handoff, slow_handoff_reply); + // A failed first AP attempt after a long successful scan must report + // failure truthfully and return within the console reply deadline. + reset(Fault::StaleSsid); WiFi.scan_behavior = ScanBehavior::SlowComplete; + WiFi.shared_espnow = true; WiFi.delayed_stop = true; + WiFi.stop_delay_ms = 280; WiFi.config_delay_ms = 100; + WiFi.live_mode = WIFI_AP_STA; WiFi.ap_started = true; + WebConfigServer slow_scan_failure; char slow_scan_failure_reply[160] = {}; + const uint32_t slow_scan_failure_started = millis(); + assert(!slow_scan_failure.startSetupMode(slow_scan_failure_reply)); + assert(millis() - slow_scan_failure_started < 4500); + assert(!slow_scan_failure._dns && slow_scan_failure.servers == 0 && WiFi.scans == 1); + assert(!strstr(slow_scan_failure_reply, "AP started")); + // Maximum scan time, delayed SDK cancellation, old AP shutdown, and + // unsuccessful AP readiness may exceed 4.5 seconds together. They must + // still fit the actual five-second console reply deadline. + reset(Fault::StaleSsid); WiFi.scan_behavior = ScanBehavior::StopDelayed; + WiFi.shared_espnow = true; WiFi.delayed_stop = true; + WiFi.stop_delay_ms = 280; WiFi.config_delay_ms = 100; + WiFi.live_mode = WIFI_AP_STA; WiFi.ap_started = true; + WebConfigServer exhausted_scan; char exhausted_scan_reply[160] = {}; + const uint32_t exhausted_scan_started = millis(); + assert(!exhausted_scan.startSetupMode(exhausted_scan_reply)); + assert(millis() - exhausted_scan_started < 5000); + assert(!exhausted_scan._dns && exhausted_scan.servers == 0 && WiFi.scans == 1); + assert(WiFi.sdk_scan_stops == 1 && !WiFi.sdk_scan_active); + assert(!strstr(exhausted_scan_reply, "AP started")); + for (ScanBehavior scan_behavior : {ScanBehavior::Failure, ScanBehavior::Stuck, + ScanBehavior::StopDelayed, ScanBehavior::FacadeFailedActive}) { + reset(); WiFi.scan_behavior = scan_behavior; + WebConfigServer portal; char reply[160] = {}; + const uint32_t started = millis(); + assert(portal.startSetupMode(reply)); + assert(millis() - started < 4500); + requireEndpoint(portal, reply, false); + // An ordinary picker request must preserve the stable AP after a failed + // pre-scan. A user-requested rescan can retry without an automatic loop. + const unsigned scan_stops = WiFi.sdk_scan_stops; + const unsigned scan_deletes = WiFi.scan_deletes; + AsyncWebServerRequest scan; + for (unsigned request = 0; request < 2; ++request) { + portal.handleScan(&scan); + assert(scan.status == 200); + assert(scan.payload.find("\"state\":\"done\"") != std::string::npos); + assert(scan.payload.find("\"networks\":[]") != std::string::npos); + assert(scan.payload.find("\"scan_failed\":true") != std::string::npos); + assert(WiFi.scans == 1 && WiFi.scans_during_ap == 0); + assert(WiFi.sdk_scan_stops == scan_stops && WiFi.scan_deletes == scan_deletes); + assert(!WiFi.sdk_scan_active && WiFi.scan_result == WIFI_SCAN_FAILED); + } + WiFi.scan_behavior = ScanBehavior::Complete; + scan.rescan = true; + portal.handleScan(&scan); + assert(scan.status == 200 && scan.payload == "{\"state\":\"scanning\"}"); + assert(WiFi.scans == 2 && WiFi.scans_during_ap == 1 && WiFi.sdk_scan_active); + portal.handleScan(&scan); + assert(WiFi.scans == 2); + delay(100); scan.rescan = false; + portal.handleScan(&scan); + assert(scan.payload == "{\"state\":\"done\"}" && scan.completed_networks == 2); + assert(WiFi.scans == 2 && WiFi.scan_deletes == scan_deletes); + assert(WiFi.sdk_scan_stops == scan_stops && !WiFi.sdk_scan_active); + } + for (ScanBehavior scan_behavior : {ScanBehavior::Unstoppable, ScanBehavior::FacadeFailedStopRejected}) { + reset(); WiFi.scan_behavior = scan_behavior; WiFi.shared_espnow = true; + WebConfigServer stuck_scanner; char stuck_reply[160] = {}; + const uint32_t stuck_started = millis(); + assert(!stuck_scanner.startSetupMode(stuck_reply)); + assert(millis() - stuck_started < 4500 && WiFi.scans == 1 && WiFi.sdk_scan_stops == 1); + assert(WiFi.sdk_scan_active && WiFi.ap_calls == 0 && WiFi.config_calls == 0); + assert(!stuck_scanner._dns && stuck_scanner.servers == 0 && !strstr(stuck_reply, "AP started")); + } + // Reuse after an OTA AP: the existing SSID/protocol must be replaced, while + // the shared STA driver remains alive. Repeat after the setup AP is stopped. + reset(); WiFi.shared_espnow = true; WiFi.delayed_stop = true; + WiFi.live_mode = WIFI_AP_STA; WiFi.ap_started = true; + memcpy(WiFi.live_config.ap.ssid, "MeshCore-OTA", 12); + WiFi.live_config.ap.ssid_len = 12; + WiFi.live_protocol = WIFI_PROTOCOL_LR; + for (int iteration = 0; iteration < 2; ++iteration) { + WebConfigServer portal; char reply[160] = {}; + assert(portal.startSetupMode(reply)); + requireEndpoint(portal, reply); + WiFi.softAPdisconnect(true); + assert(WiFi.live_mode == WIFI_STA && WiFi.initialized); + WiFi.scans = WiFi.scan_deletes = DNSServer::starts = 0; + WiFi.stale_picker_results = 2; + } + reset(); mesh::wireless::control().disabled = true; + WebConfigServer disabled; char reply[160] = {}; + assert(!disabled.startSetupMode(reply) && WiFi.ap_calls == 0 && !disabled._dns); + // Promoting an existing LAN listener changes its network, not its lifetime. + reset(); WiFi.shared_espnow = true; + WebConfigServer promoted; + promoted._mode = WebConfigServer::MODE_LAN; + promoted._owns_wifi = false; + promoted.servers = 1; + assert(promoted.startSetupMode(reply)); + requireEndpoint(promoted, reply); + reset(Fault::StaleSsid); WiFi.shared_espnow = true; + WebConfigServer failed_promotion; + failed_promotion._mode = WebConfigServer::MODE_LAN; + failed_promotion._owns_wifi = false; + failed_promotion.servers = 1; + assert(!failed_promotion.startSetupMode(reply)); + assert(failed_promotion._mode == WebConfigServer::MODE_LAN); + assert(failed_promotion.servers == 1 && !failed_promotion._dns); + assert(!strstr(reply, "AP started") && DNSServer::starts == 0 && WiFi.scans == 1); + assert(WiFi.initialized && WiFi.live_mode == WIFI_STA); + // Existing setup, owned LAN, and pending teardown must reject a new start. + for (int busy_case = 0; busy_case < 3; ++busy_case) { + reset(); WebConfigServer busy; + if (busy_case == 0) busy._mode = WebConfigServer::MODE_SETUP; + else if (busy_case == 1) busy._mode = WebConfigServer::MODE_LAN; + else busy._stopping = true; + assert(!busy.startSetupMode(reply)); + assert(strstr(reply, "busy") && WiFi.ap_calls == 0 && !busy._dns && busy.servers == 0); + } + // The first browser request reuses the completed pre-AP scan. Only an + // explicit rescan starts scanning while the AP is serving clients. + for (bool shared_espnow : {false, true}) { + reset(); WiFi.shared_espnow = shared_espnow; + WebConfigServer portal; + assert(portal.startSetupMode(reply)); + requireEndpoint(portal, reply); + AsyncWebServerRequest scan; + portal.handleScan(&scan); + assert(scan.status == 200 && scan.payload == "{\"state\":\"done\"}" && scan.completed_networks == 2); + assert(WiFi.scans == 1 && WiFi.scan_deletes == 1); + assert(WiFi.departed_home_channel == !shared_espnow); + assert(WiFi.scans_during_ap == 0); + scan.rescan = true; + portal.handleScan(&scan); + assert(scan.status == 200 && scan.payload == "{\"state\":\"scanning\"}"); + assert(WiFi.scans == 2 && WiFi.scan_deletes == 2 && WiFi.scans_during_ap == 1); + portal.handleScan(&scan); + assert(WiFi.scans == 2); // Existing request still owns the scan. + delay(100); scan.rescan = false; + portal.handleScan(&scan); + assert(scan.payload == "{\"state\":\"done\"}" && scan.completed_networks == 2); + assert(WiFi.scans == 2 && WiFi.scan_deletes == 2 && !WiFi.stale_picker_results); + } + // LAN mode has no AP startup pre-scan. Its first picker request must still + // initiate scanning automatically when no results are available. + for (bool shared_espnow : {false, true}) { + reset(); WiFi.shared_espnow = shared_espnow; + WiFi.scan_result = WIFI_SCAN_FAILED; + WebConfigServer lan; + lan._mode = WebConfigServer::MODE_LAN; lan._owns_wifi = false; lan.servers = 1; + AsyncWebServerRequest scan; + lan.handleScan(&scan); + assert(scan.status == 200 && scan.payload == "{\"state\":\"scanning\"}"); + assert(WiFi.scans == 1 && WiFi.sdk_scan_active && WiFi.scans_during_ap == 0); + assert(WiFi.scan_deletes == 0 && WiFi.sdk_scan_stops == 0); + lan.handleScan(&scan); + assert(WiFi.scans == 1); + delay(100); + lan.handleScan(&scan); + assert(scan.payload == "{\"state\":\"done\"}" && scan.completed_networks == 2); + assert(WiFi.scans == 1 && !WiFi.sdk_scan_active); + assert(lan._mode == WebConfigServer::MODE_LAN && lan.servers == 1 && !lan._dns); + } + reset(); WebConfigServer inactive; + AsyncWebServerRequest inactive_request; + inactive.handleScan(&inactive_request); + assert(inactive_request.status == 503 && WiFi.scans == 0 && WiFi.scan_queries == 0); + inactive._mode = WebConfigServer::MODE_SETUP; inactive.auth_allowed = false; + inactive.handleScan(&inactive_request); + assert(inactive_request.status == 401 && WiFi.scans == 0 && WiFi.scan_queries == 0); + puts("WebConfig AP startup regression checks passed"); +} diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index eee37059a..34b26df41 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -1278,17 +1278,24 @@ uses_merged_standard_usb_logging esp_repeater \ # were dead ends. verify_esp32_field_browser_ota() { local env_name=$1 + local minimal_lora_ota=${2:-0} local PLATFORMIO_BUILD_FLAGS="" local PLATFORMIO_BUILD_UNFLAGS="" local BUILD_PROFILE_FOR_TARGET=standard local ESP32_FULL_BUILD=0 + local MESHDEBUG_OVERRIDE=off + local PACKET_LOGGING_OVERRIDE=off + local MQTT_BRIDGE_OVERRIDE=off + local FIRMWARE_FILENAME_INFIX="" + local PIO_CONFIG_JSON='[]' local -a BUILD_CAPABILITIES=() local -a BUILD_REDUCTIONS=() local -a BUILD_EXPECTATIONS=() - local capabilities reductions expectations + local capabilities reductions expectations compact_flag PIO_ENV_PLATFORM_BY_NAME[$env_name]=ESP32_PLATFORM - PIO_ENV_OTA_BY_NAME[$env_name]=0 + PIO_ENV_OTA_BY_NAME[$env_name]=$minimal_lora_ota + apply_lora_ota_override "$env_name" apply_esp32_lora_ota_size_profile "$env_name" [[ "$PLATFORMIO_BUILD_FLAGS" == *-DLIGHTWEIGHT_WIFI_OTA=1* ]] \ @@ -1308,11 +1315,46 @@ verify_esp32_field_browser_ota() { expectations=" ${BUILD_EXPECTATIONS[*]} " [[ "$expectations" == *"web.lightweight_browser_ota=MeshCore firmware update"* ]] \ || fail "$env_name does not verify browser OTA in the linked image" + for compact_flag in ED25519_COMPACT_BASE ED25519_COMPACT_SHA512 OTA_TARGET_NAME_FRONT_CODED; do + if [ "$minimal_lora_ota" = 1 ]; then + [[ "$PLATFORMIO_BUILD_FLAGS" == *"-D${compact_flag}=1"* ]] \ + || fail "$env_name did not enable its feature-preserving flash trim" + else + [[ "$PLATFORMIO_BUILD_FLAGS" != *"${compact_flag}"* ]] \ + || fail "$env_name changed the ordinary standard implementation" + fi + done + if [ "$minimal_lora_ota" = 1 ]; then + [[ "$PLATFORMIO_BUILD_FLAGS" == *-DENABLE_OTA=1* \ + && "$PLATFORMIO_BUILD_FLAGS" == *-DOTA_FLASH_STORE=1* \ + && "$PLATFORMIO_BUILD_FLAGS" == *-DOTA_FOLDER_SERIAL* ]] \ + || fail "$env_name removed a LoRa OTA or USB seeding feature to fit" + [[ "$expectations" == *"ota.update.lora=image_hash MISMATCH after decode"* \ + && "$expectations" == *"ota.cli=OTA: status"* ]] \ + || fail "$env_name stopped verifying its LoRa OTA implementation" + fi } verify_esp32_field_browser_ota heltec_v4_repeater verify_esp32_field_browser_ota heltec_v4_room_server verify_esp32_field_browser_ota heltec_v4_sensor +verify_esp32_field_browser_ota heltec_v4_repeater_lora_ota_no_external_sensors 1 +verify_esp32_field_browser_ota heltec_v4_room_server_lora_ota_no_external_sensors 1 + +# The new storage representation is confined to portable ESP32 minimal OTA +# artifacts. Full images and other platforms retain their existing policies. +( + PLATFORMIO_BUILD_FLAGS="" + PLATFORMIO_BUILD_UNFLAGS="" + BUILD_PROFILE_FOR_TARGET=full + ESP32_FULL_BUILD=1 + apply_esp32_lora_ota_size_profile heltec_v4_repeater_lora_ota_no_external_sensors + [ -z "$PLATFORMIO_BUILD_FLAGS" ] || fail "Full image received portable flash trims" + BUILD_PROFILE_FOR_TARGET=standard + ESP32_FULL_BUILD=0 + apply_esp32_lora_ota_size_profile nrf_repeater_lora_ota_no_external_sensors + [ -z "$PLATFORMIO_BUILD_FLAGS" ] || fail "nRF52 image received ESP32 portable flash trims" +) # Personal/attached roles can retain their established transport policy; the # fail-safe is deliberately scoped to remotely installed field/server images. diff --git a/test/test_esp32_wifi_scan_fix.py b/test/test_esp32_wifi_scan_fix.py new file mode 100644 index 000000000..2380f9ee6 --- /dev/null +++ b/test/test_esp32_wifi_scan_fix.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Execute the pinned scan method with poisoned locals at its SDK boundary.""" +import configparser +import hashlib +import importlib.util +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "test/fixtures/esp32_wifi_scan" +SPEC = importlib.util.spec_from_file_location("wifi_scan_fix", ROOT / "scripts/esp32_wifi_scan_fix.py") +FIX = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(FIX) +RAW = (FIXTURES / "WiFiScan.cpp").read_text(encoding="utf-8") +PATCHED = FIX.patched_scan_source(RAW) +VERSION = ROOT / "test/fixtures/esp32_file_buffer/esp_arduino_version.h" + + +def scan_method(source): + start = source.index("int16_t WiFiScanClass::scanNetworks(") + opening = source.index("{", start) + depth = 0 + for end in range(opening, len(source)): + depth += (source[end] == "{") - (source[end] == "}") + if depth == 0: + return source[start:end + 1] + raise AssertionError("unclosed scan method") + + +PREFIX = r''' +#include +#include +#include +#include +#include "wifi_scan_config.h" +static constexpr int ESP_OK = 0; +static constexpr int WIFI_SCAN_RUNNING = -1, WIFI_SCAN_FAILED = -2; +static constexpr uint32_t WIFI_SCANNING_BIT = 1, WIFI_SCAN_DONE_BIT = 2; +static uint32_t status_bits = 0; +static unsigned enabled_calls = 0, deleted_calls = 0, sdk_calls = 0; +static wifi_scan_config_t captured; +static uint32_t millis() { return 42; } +struct WiFiGenericClass { + static uint32_t getStatusBits() { return status_bits; } + static void clearStatusBits(uint32_t bits) { status_bits &= ~bits; } + static void setStatusBits(uint32_t bits) { status_bits |= bits; } + static uint32_t waitStatusBits(uint32_t bits, uint32_t) { return status_bits & bits; } +}; +struct WiFiMock { void enableSTA(bool enabled) { assert(enabled); ++enabled_calls; } } WiFi; +struct WiFiScanClass { + static uint32_t _scanTimeout, _scanStarted; + static bool _scanAsync; + static uint16_t _scanCount; + static void scanDelete() { ++deleted_calls; } + int16_t scanNetworks(bool, bool, bool, uint32_t, uint8_t, const char*, const uint8_t*); +}; +uint32_t WiFiScanClass::_scanTimeout = 0, WiFiScanClass::_scanStarted = 0; +bool WiFiScanClass::_scanAsync = false; +uint16_t WiFiScanClass::_scanCount = 0; +static int esp_wifi_scan_start(const wifi_scan_config_t* config, bool block) { + assert(!block); + captured = *config; + ++sdk_calls; + return ESP_OK; +} +// GCC 11 has no automatic-local poisoning flag. The negative control alone +// models a poisoned prior stack in that case; production initialization stays +// unchanged. All fields interpreted at the SDK boundary are then overwritten +// by the original method or consist of unsigned integers. +static wifi_scan_config_t poisonedScanConfig() { + wifi_scan_config_t config; + std::memset(&config, 0xa5, sizeof(config)); + return config; +} +''' + +MAIN = r''' +int main() { + WiFiScanClass scanner; + const char ssid[] = "filter"; + const uint8_t bssid[] = {1, 2, 3, 4, 5, 6}; + for (bool passive : {false, true}) { + for (uint32_t dwell : {120u, 200u, 300u}) { + status_bits = 0; + assert(scanner.scanNetworks(true, true, passive, dwell, 6, ssid, bssid) + == WIFI_SCAN_RUNNING); + assert(captured.ssid == reinterpret_cast(ssid)); + assert(captured.bssid == bssid && captured.channel == 6 && captured.show_hidden); + assert(captured.scan_type == (passive ? WIFI_SCAN_TYPE_PASSIVE : WIFI_SCAN_TYPE_ACTIVE)); + assert(WiFiScanClass::_scanTimeout == dwell * 20 && WiFiScanClass::_scanStarted == 42); + assert((status_bits & WIFI_SCANNING_BIT) && !(status_bits & WIFI_SCAN_DONE_BIT)); + if (captured.home_chan_dwell_time != 0 + || (passive && (captured.scan_time.active.min != 0 || captured.scan_time.active.max != 0)) + || (!passive && captured.scan_time.passive != 0)) { + std::puts("scan defaults were not zeroed"); + return 1; + } + if (passive) assert(captured.scan_time.passive == dwell); + else assert(captured.scan_time.active.min == 100 && captured.scan_time.active.max == dwell); + } + } + assert(enabled_calls == 6 && deleted_calls == 6 && sdk_calls == 6); + // An existing scan must return without starting or modifying another scan. + assert(scanner.scanNetworks(true, false, false, 200, 0, nullptr, nullptr) == WIFI_SCAN_RUNNING); + assert(sdk_calls == 6 && enabled_calls == 6 && deleted_calls == 6); + status_bits = 0; + assert(scanner.scanNetworks(true, false, false, 200, 0, nullptr, nullptr) == WIFI_SCAN_RUNNING); + assert(!captured.ssid && !captured.bssid && captured.channel == 0 && !captured.show_hidden); + std::puts("WiFi scan SDK configuration checks passed"); +} +''' + + +class Node: + def __init__(self, path): self.path = path + def srcnode(self): return self + def get_abspath(self): return str(self.path) + + +class Env(dict): + def __init__(self, defines, build): + super().__init__(CPPDEFINES=defines) + self.build, self.paths, self.middleware = build, [], [] + def subst(self, value): + assert value == "$BUILD_DIR" + return str(self.build) + def File(self, path): return path + def AppendUnique(self, **kwargs): + for path in kwargs["CPPPATH"]: + if path not in self.paths: self.paths.append(path) + def AddBuildMiddleware(self, callback, pattern): self.middleware.append((callback, pattern)) + + +class WiFiScanFixTest(unittest.TestCase): + def test_actual_scan_method_zeroes_unused_fields_with_poisoned_negative_control(self): + compiler = os.environ.get("CXX") or shutil.which("c++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "A host C++11 compiler is required") + probe = subprocess.run([compiler, "-x", "c++", "-std=c++11", + "-ftrivial-auto-var-init=pattern", "-fsyntax-only", "-"], + input="int main() {}\n", capture_output=True, text=True, timeout=30) + poison_flags = ["-ftrivial-auto-var-init=pattern"] if probe.returncode == 0 else [] + with tempfile.TemporaryDirectory(prefix="mesh-wifi-scan-sdk-") as directory: + temp = Path(directory) + shutil.copyfile(FIXTURES / "wifi_scan_config.h", temp / "wifi_scan_config.h") + for source, expected in ((PATCHED, 0), (RAW, 1)): + with self.subTest(patched=expected == 0): + method = scan_method(source) + if expected and not poison_flags: + method = method.replace(FIX.ORIGINAL_DECLARATION, + "wifi_scan_config_t config = poisonedScanConfig();", 1) + generated = temp / "scan.cpp" + generated.write_text(PREFIX + "\n#include \n" + + method + MAIN, encoding="utf-8") + binary = temp / "scan.exe" + built = subprocess.run([compiler, "-std=c++11", "-O1", "-Wall", "-Wextra", + "-Werror", "-Wno-unused-function", *poison_flags, + str(generated), "-o", str(binary)], capture_output=True, text=True, timeout=30) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(checked.returncode, expected, checked.stdout + checked.stderr) + self.assertIn("WiFi scan SDK configuration checks passed" if expected == 0 + else "scan defaults were not zeroed", checked.stdout) + + def test_exact_source_pin_transform_idempotence_and_changed_source_rejection(self): + self.assertEqual(hashlib.sha256((FIXTURES / "WiFiScan.cpp").read_bytes()).hexdigest(), + FIX.PINNED_SCAN_SHA256) + self.assertEqual(hashlib.sha256((FIXTURES / "wifi_scan_config.h").read_bytes()).hexdigest(), + "9b25412ea07a0a8fe295d02c9f0ddaee2bc984397483d5d56db7e54b5aacf2ce") + self.assertEqual(PATCHED, RAW.replace(FIX.ORIGINAL_DECLARATION, FIX.ZEROED_DECLARATION, 1)) + self.assertEqual(FIX.patched_scan_source(RAW.replace("\n", "\r\n")), PATCHED) + self.assertEqual(FIX.patched_scan_source(PATCHED), PATCHED) + for changed in (RAW + "\n", PATCHED + "\n", RAW.replace("active.min = 100", "active.min = 50")): + with self.assertRaises(RuntimeError): FIX.patched_scan_source(changed) + for version in ((2, 0, 18), (3, 1, 3), (3, 3, 11), (3, 3, 12)): + with self.assertRaises(RuntimeError): FIX.patched_scan_source(RAW, version) + + def test_private_copy_preserves_shared_framework_and_is_idempotent(self): + with tempfile.TemporaryDirectory(prefix="mesh-wifi-scan-copy-") as directory: + temp = Path(directory) + source = temp / "sdk/libraries/WiFi/src/WiFiScan.cpp" + source.parent.mkdir(parents=True) + source.write_bytes((FIXTURES / "WiFiScan.cpp").read_bytes()) + header = temp / "sdk/cores/esp32/esp_arduino_version.h" + header.parent.mkdir(parents=True) + header.write_bytes(VERSION.read_bytes()) + node = Node(source) + for defines in ([], ["RP2040_PLATFORM"], [("ESP32_PLATFORM", 0)]): + env = Env(defines, temp / "untouched") + self.assertIs(FIX.replace_scan_source(env, node), node) + self.assertFalse(env.build.exists()) + for index, defines in enumerate((["ESP32_PLATFORM"], [("ESP32", 1)], + ["ESP32_PLATFORM", ("ARDUINO_USB_MODE", 0)], + ["ESP32_PLATFORM", ("ARDUINO_USB_MODE", 1)])): + env = Env(defines, temp / ("build" + str(index))) + result = Path(FIX.replace_scan_source(env, node)) + self.assertEqual(result.read_text(encoding="utf-8"), PATCHED) + self.assertEqual(result, env.build / "patched-esp32-wifi/WiFiScan.cpp") + self.assertEqual(env.paths, [str(source.parent)]) + self.assertEqual(source.read_bytes(), (FIXTURES / "WiFiScan.cpp").read_bytes()) + self.assertEqual(header.read_bytes(), VERSION.read_bytes()) + before = result.stat().st_mtime_ns + self.assertEqual(Path(FIX.replace_scan_source(env, node)).stat().st_mtime_ns, before) + env = Env(["ESP32"], temp / "registration") + unrelated = Node(temp / "libraries/WiFi/src/Other.cpp") + self.assertIs(FIX.replace_scan_source(env, unrelated), unrelated) + FIX.install(env) + self.assertEqual(env.middleware, + [(FIX.replace_scan_source, "*libraries*WiFi*src*WiFiScan.cpp")]) + + def test_known_arduino3_is_unchanged_unknown_or_malformed_framework_fails_closed(self): + with tempfile.TemporaryDirectory(prefix="mesh-wifi-scan-version-") as directory: + temp = Path(directory) + source = temp / "sdk/libraries/WiFi/src/WiFiScan.cpp" + source.parent.mkdir(parents=True) + source.write_text("Arduino 3 source is outside this narrow patch", encoding="utf-8") + header = temp / "sdk/cores/esp32/esp_arduino_version.h" + header.parent.mkdir(parents=True) + env, node = Env(["ESP32_PLATFORM"], temp / "build"), Node(source) + def version_text(version): + return "".join(f"#define ESP_ARDUINO_VERSION_{key} {value}\n" + for key, value in zip(("MAJOR", "MINOR", "PATCH"), version)) + for version in ((3, 1, 3), (3, 3, 11)): + header.write_text(version_text(version)) + self.assertEqual(FIX.require_known_framework(source), version) + self.assertIs(FIX.replace_scan_source(env, node), node) + self.assertFalse(env.build.exists()) + for changed in ("", version_text((2, 0, 18)), version_text((3, 3, 12)), + version_text((2, 0, 17)) + "#define ESP_ARDUINO_VERSION_MAJOR 2\n"): + header.write_text(changed) + with self.assertRaises(RuntimeError): FIX.replace_scan_source(env, node) + self.assertFalse(env.build.exists()) + header.unlink() + with self.assertRaises(RuntimeError): FIX.replace_scan_source(env, node) + header.write_bytes(VERSION.read_bytes()) + with self.assertRaises(RuntimeError): FIX.replace_scan_source(env, node) + self.assertFalse(env.build.exists()) + + def test_common_esp32_registration(self): + config = configparser.ConfigParser(interpolation=None) + config.read(ROOT / "platformio.ini") + self.assertIn("pre:scripts/esp32_wifi_scan_fix.py", config["esp32_base"]["extra_scripts"].split()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_webconfig_ap_start.py b/test/test_webconfig_ap_start.py new file mode 100644 index 000000000..7ded4f107 --- /dev/null +++ b/test/test_webconfig_ap_start.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Exercise production WebConfig AP startup against independent SDK state.""" +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "src/helpers/esp32/WebConfigServer.cpp" +FIXTURE = ROOT / "test/fixtures/webconfig_ap_start.cpp" + + +class WebConfigApStartTest(unittest.TestCase): + def test_live_ap_state_and_reusable_handoff(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "A host C++17 compiler is required") + source = SOURCE.read_text(encoding="utf-8") + # Keeping helper extraction optional lets the old implementation compile: + # its false-success behavior must fail executable checks, not compilation. + helper = "" + if "static bool setupAccessPointStarted(" in source: + helper += extract_braced(source, "static bool setupAccessPointStarted(") + "\n" + if "static bool setupAccessPointReady(" in source: + helper += extract_braced(source, "static bool setupAccessPointReady(") + "\n" + if "static bool scanSetupNetworks(" in source: + helper += extract_braced(source, "static bool scanSetupNetworks(") + method = extract_braced(source, "bool WebConfigServer::startSetupMode(") + scan_handler = extract_braced(source, "void WebConfigServer::handleScan(") + policy = (ROOT / "src/helpers/esp32/WiFiRadioPolicy.h").read_text(encoding="utf-8") + mask = policy[policy.index("static constexpr uint8_t kAccessPointProtocolMask ="):] + mask = mask[:mask.index(";") + 1] + fixture = FIXTURE.read_text(encoding="utf-8").replace("@AP_MASK@", mask) + fixture = fixture.replace("@METHODS@", helper + "\n" + method + "\n" + scan_handler) + with tempfile.TemporaryDirectory(prefix="webconfig-ap-start-") as directory: + work = Path(directory) + generated = work / "fixture.cpp" + generated.write_text(fixture, encoding="utf-8") + binary = work / "webconfig-ap-start.exe" + sanitizer = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) + for arduino_major, protected in ((2, False), (2, True), (3, False), (3, True)): + with self.subTest(arduino_major=arduino_major, protected=protected): + security_flags = ['-DWEBCONFIG_AP_PASSWORD="testpass"'] if protected else [] + compiled = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", + *sanitizer, f"-DESP_ARDUINO_VERSION_MAJOR={arduino_major}", + *security_flags, + str(generated), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + checked = subprocess.run([str(binary)], capture_output=True, + text=True, timeout=15) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn("WebConfig AP startup regression checks passed", checked.stdout) + + +if __name__ == "__main__": + unittest.main()