First async hardware example for rustyfarian-esp-hal-wifi.
Demonstrates embassy-based Wi-Fi connection on ESP32-C3 using the WiFiManagerAsync API.
Serves as the hardware validation for both embassy-feature-flag-v1 and wifi-manager-async-v1.
Depends on embassy-feature-flag-v1 and wifi-manager-async-v1.
Source: docs/embassy-integration-research.md — example code sketch under Option A.
| Decision | Reason | Rejected Alternative |
|---|---|---|
| Target ESP32-C3 first (not C6 or S3) | Blocking path is validated on C3; research notes a pending C6 bug in esp-radio; S3 is Xtensa and adds toolchain cost |
C6 first — blocked by known bug; S3 first — Xtensa complexity on top of new async code |
#[esp_rtos::main] async entry point |
Required by embassy-on-esp-rtos; matches the example sketch in the research doc | Manual Executor::new().run() — more boilerplate, no benefit |
Two esp_alloc::heap_allocator! calls: 64 KiB reclaimed IRAM + 36 KiB DRAM on C6-style chips; single 72 KiB call on C3 |
Research doc explains Wi-Fi RX/TX DMA needs reclaimed IRAM on C6; C3 has contiguous SRAM and doesn't need the split | Single-region heap on C6 — DMA failures; two-region on C3 — unnecessary complexity |
Example prints the acquired IP to esp-println and then idles in a 10 s loop |
Minimal viable demo; matches hal_c3_connect blocking example pattern |
Opening a TCP socket and pinging a server — expands scope beyond "did it connect" |
Two spawned tasks: wifi_task (controller) + net_task (runner) |
Canonical embassy-net pattern; keeps the main task free for application logic | Single combined task — couples concerns, harder to extend |
Credentials via env!("WIFI_SSID") / env!("WIFI_PASS") at compile time |
Matches pattern already used by hal_c3_connect and idf_esp32s3_join; no runtime cost, no secrets in repo |
option_env! with defaults — risks shipping an example that "works" with empty credentials |
Example lives in crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async.rs |
Sibling to existing blocking example; discoverable | Separate examples crate — unnecessary indirection |
Gated behind #![cfg(feature = "embassy")] at the example level |
Prevents the example from breaking default-feature builds; matches how feature-gated examples work elsewhere | Always-on — forces embassy deps into default builds, violates feature flag design |
scripts/build-example.sh routes hal_c3_connect_async the same way as hal_c3_connect, with --features embassy added |
Existing script already handles hal_* prefixed examples for bare-metal; only the feature flag differs |
New dedicated script — duplication of toolchain sourcing and target selection logic |
- Must build via
just build-example hal_c3_connect_async - Must flash and run on real ESP32-C3 hardware
- Must acquire a DHCPv4 lease from a real access point and print the IP
- Must not regress
hal_c3_connect(blocking example) just verifymust remain green — the example does not enter the default verification build because it requires theembassyfeaturejust check-embassy(added in feature 1) should cover the example incargo checkform if feasible
-
just build-example hal_c3_connect_asyncsucceeds -
just flash hal_c3_connect_asyncflashes cleanly - Serial output shows "Wi-Fi connected" and a valid IP address from DHCP
- Example continues running without panic for at least 5 minutes
- Manually disconnecting the AP triggers the reconnect loop (via
wait_for_event(StaDisconnected)) - Re-connecting the AP brings the example back online without reset
- Heap headroom remains stable across disconnect/reconnect cycles (no obvious leak)
- Is the ESP32-C3 heap layout a single
heap_allocator!(size: 72 * 1024)call, or should it also split into reclaimed + DRAM regions? — Single 72 KiB call; C3 has contiguous SRAM, no need for the C6 two-region split - Should the example include a visible LED indicator? — Out of scope; see
led-task-embassy-v1(future feature) - Do we need a
build.rs/sdkconfig.defaultsfor bare-metal? — No; pureesp-hal+esp-radio+esp-rtos, no ESP-IDF involvement - Bootloader situation on C3 bare-metal? — Use espflash's bundled bootloader, same as the existing
hal_c3_connectblocking example (no custom routing needed)
- Design approved
-
embassy-feature-flag-v1landed (blocker) -
wifi-manager-async-v1landed (blocker) - Example file created (
crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async.rs) -
just build-example hal_c3_connect_asyncsucceeds (release profile,riscv32imc-unknown-none-elf, all deps compile clean) - Hardware validation checklist complete — connect + DHCP verified; AP reconnect loop still open
- CHANGELOG entry
- 2026-04-08 — Feature doc created from
docs/embassy-integration-research.md - 2026-04-08 — Implemented:
examples/hal_c3_connect_async.rsusing#[esp_rtos::main]with two spawned tasks (wifi_task+net_task). DestructuresAsyncWifiHandle(stack isCopy, keeps main's reference while moving controller/runner into tasks).esp_alloc::heap_allocator!(size: 72 * 1024)— single-region on C3.WiFiManager::init_asyncinternally callsesp_rtos::start()viainit_inner, which works from inside the embassy executor created by#[esp_rtos::main](the macro creates the executor but does not start the RTOS — that is still the user's/library's responsibility). Added[[example]] required-features = ["esp32c3", "rt", "embassy"]to the crate Cargo.toml.scripts/build-example.shgrew a*_async*case that appends theembassyfeature automatically, mirroring the existing*_rgb*pattern.just fmt,just verify, andjust build-example hal_c3_connect_asyncall pass clean. - 2026-04-10 — Fixed
scripts/flash.shmissing the*_async*→embassyfeature detection thatbuild-example.shalready had. Hardware validation on real ESP32-C3: build, flash, Wi-Fi connect, and DHCP lease all confirmed working. AP reconnect loop test still pending.
- Hardware: ESP32-C3 Super Mini
- Network: WPA2 AP with two virtual SSIDs on the same physical radio
- Crate stack:
rustyfarian-esp-hal-wifiv0.2.1,esp-radio 0.18.0,esp-rtos 0.2.0 - Reference:
rustyfarian-esp-idf-wifiusingesp-idf-svcconnects without issue on the same C3 board
connect_async() always fails with:
connect failed: Disconnected(DisconnectedStationInfo {
ssid: "<ssid>",
reason: AuthenticationExpired,
rssi: -33
})
WIFI_REASON_AUTH_EXPIRE = reason code 2.
The AP sends a Deauthentication frame before the WPA2 4-way handshake completes.
Signal strength is excellent (-33 to -40 dBm) — not a range issue.
- Two virtual SSIDs on the same physical AP; same channel 11.
- Secondary SSID appeared in
scan_asyncresults; primary target SSID did NOT appear in the scan despite excellent signal. - The ESP-IDF C stack is used by both IDF and esp-radio; differences are in how they call it.
- esp-radio 0.18.0
wifi_init_config_thasnvs_enable: 0— NVS (PMKSA cache) is disabled. apply_sta_configin esp-radio setspmf_cfg: { capable: true, required: false }(hardcoded, not configurable viaStationConfig).StationConfig::default()fields:auth_method: Wpa2Personal,failure_retry_cnt: 1,beacon_timeout: 6,scan_method: Fast.- These map to identical
wifi_sta_config_tC fields asesp-idf-svc'sClientConfiguration::default()— no difference found at the C config level.
Hypothesis: scan_async clears the station config stored in the Wi-Fi driver. After the scan, connect_async runs with empty SSID/password and the AP rejects the association.
Change: Added controller.set_config(&Config::Station(station)) inside the wifi_task loop, immediately before every connect_async() call. Also applied to both LED examples and lib.rs.
Result (hardware log):
Scanning...
AccessPointInfo { ssid: "<secondary-ssid>", channel: 11,
signal_strength: -73, auth_method: Some(Wpa2Personal), ... }
Waiting for DHCPv4 lease...
connect failed: Disconnected(..., reason: AuthenticationExpired, rssi: -40)
Scan produced output (confirmed set_config was applied). Primary SSID still not in scan results. Auth still fails. Hypothesis disproved — the station config was not the cause.
Hypothesis: The full channel scan (active, 10–20 ms per channel) puts the radio in a post-scan state that adds latency to the subsequent auth exchange. The target SSID is not in the scan's BSSID→channel cache, so connect_async must probe for it internally, adding further latency. Combined, the ESP32's own auth timer expires before the AP's response arrives. Supporting reasoning: the IDF variant does not scan at all and succeeds.
Change: Removed scan_async and its import from hal_c3_connect_async.rs. Cleaned up stale "scan clears config" comments in LED examples and lib.rs.
Result (hardware log):
Initializing Wi-Fi (async)...
INFO - Wi-Fi configured, power save: None
Waiting for DHCPv4 lease...
connect failed: Disconnected(..., reason: AuthenticationExpired, rssi: -33)
connect failed: Disconnected(..., reason: AuthenticationExpired, rssi: -33)
Scan removal made no difference. Hypothesis disproved. The scan was not interfering with auth timing.
Root cause confirmed: ESP32-C3 Super Mini PCB antenna reflects RF back into the chip at full TX power (~20 dBm), corrupting WPA2 auth frames.
Reproduced on a phone hotspot (isolated from AP-specific configuration); fixed by calling esp_wifi_set_max_tx_power(34) (8.5 dBm) after set_config triggers esp_wifi_start.
See docs/project-lore.md "esp-hal April 2026 Stack" for the full entry; fix lives in WiFiManager::init_async and hal_c3_connect_async_upstream.rs.
- No
scan_asyncinhal_c3_connect_async.rs(removed; the IDF variant never scanned either). wifi_taskcallsset_configbefore everyconnect_async— retained as defensive practice.lib.rsinit_asynccallsesp_wifi_set_max_tx_power(34)immediately afterset_config.StationConfig:Wpa2Personal, no explicit BSSID, no explicit channel.