mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-20 08:02:39 +00:00
Fix V4 setup AP startup and pre-AP network scans
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Vendored
+600
@@ -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");
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user