Fix V4 setup AP startup and pre-AP network scans

This commit is contained in:
mikecarper
2026-10-06 09:40:28 -07:00
parent 8d31b3932d
commit 2ee62b9814
14 changed files with 2233 additions and 22 deletions
+8
View File
@@ -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"
@@ -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.
@@ -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"
}
}
}
+1
View File
@@ -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
+92
View File
@@ -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)
+105 -20
View File
@@ -15,6 +15,7 @@ static_assert(sizeof(WEBCONFIG_AP_PREFIX) <= 28,
#include <helpers/ui/DisplayBuildFlags.h>
#include <Preferences.h>
#include <esp_wifi.h>
#include <esp_arduino_version.h>
#include <esp_system.h>
#include <esp_heap_caps.h>
@@ -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<uint32_t>(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());
@@ -0,0 +1,2 @@
# Preserve the byte-exact upstream source, including its original whitespace.
WiFiScan.cpp -whitespace
+22
View File
@@ -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.
+281
View File
@@ -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 <stdint.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <inttypes.h>
#include <string.h>
#include <esp_err.h>
#include <esp_wifi.h>
#include <esp_event.h>
#include <esp32-hal.h>
#include <lwip/ip_addr.h>
#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<wifi_ap_record_t*>(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<wifi_ap_record_t*>(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<wifi_ap_record_t*>(_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<wifi_ap_record_t*>(_getScanInfoByIndex(i));
if(!it) {
return String();
}
return String(reinterpret_cast<const char*>(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<wifi_ap_record_t*>(_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<wifi_ap_record_t*>(_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<wifi_ap_record_t*>(_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<wifi_ap_record_t*>(_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<wifi_ap_record_t*>(_getScanInfoByIndex(i));
if(!it) {
return 0;
}
return it->primary;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2015-2023 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Apache-2.0
*/
#pragma once
#include <stdint.h>
#include <stdbool.h>
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;
+600
View File
@@ -0,0 +1,600 @@
// Only peripheral endpoints are doubles. The readiness helper and startup
// method are extracted verbatim from production WebConfigServer.cpp.
#include <algorithm>
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include <string>
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<class... Args> 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<class T> 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");
}
+44 -2
View File
@@ -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.
+250
View File
@@ -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 <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#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<const uint8_t*>(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 <initializer_list>\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()
+62
View File
@@ -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()