Skip to content

Commit 358d583

Browse files
committed
Wi-Fi example HAL ESP32C3
1 parent 9efb1b2 commit 358d583

6 files changed

Lines changed: 153 additions & 46 deletions

File tree

crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async.rs

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
//!
88
//! Two tasks are spawned alongside the main task:
99
//!
10-
//! * `wifi_task` — owns the [`WifiController`] and keeps the station
11-
//! associated, reconnecting after any `StaDisconnected` event.
10+
//! * `wifi_task` — owns the [`WifiController`], applies credentials before
11+
//! each association attempt, and reconnects after any `StaDisconnected` event.
1212
//! * `net_task` — drives the `embassy-net` stack by calling `runner.run()`.
1313
//!
1414
//! `WIFI_SSID` and `WIFI_PASS` must be set as environment variables **at build
@@ -30,7 +30,7 @@ use embassy_executor::Spawner;
3030
use embassy_time::{Duration, Timer};
3131
use esp_backtrace as _;
3232
use esp_println::println;
33-
use esp_radio::wifi::{scan::ScanConfig, Interface, WifiController};
33+
use esp_radio::wifi::{Interface, WifiController};
3434
use rustyfarian_esp_hal_wifi::{AsyncWifiHandle, WiFiConfig, WiFiConfigExt, WiFiManager};
3535

3636
esp_bootloader_esp_idf::esp_app_desc!();
@@ -73,24 +73,11 @@ async fn main(spawner: Spawner) {
7373
// Destructure: `stack` is `Copy`, so we keep our own copy before moving
7474
// `controller` and `runner` into their tasks.
7575
let AsyncWifiHandle {
76-
mut controller,
76+
controller,
7777
stack,
7878
runner,
7979
} = handle;
8080

81-
// Scan before connecting — mirrors the official embassy_dhcp example.
82-
// The active scan lets the radio settle and builds its BSSID/channel cache
83-
// before the first association attempt.
84-
println!("Scanning...");
85-
match controller.scan_async(&ScanConfig::default()).await {
86-
Ok(aps) => {
87-
for ap in &aps {
88-
println!(" {:?}", ap);
89-
}
90-
}
91-
Err(e) => println!("Scan failed (continuing anyway): {:?}", e),
92-
}
93-
9481
spawner.spawn(wifi_task(controller).unwrap());
9582
spawner.spawn(net_task(runner).unwrap());
9683

@@ -111,8 +98,8 @@ async fn main(spawner: Spawner) {
11198
}
11299

113100
// Handles both the initial association and any subsequent reconnects.
114-
// `set_config` starts the radio but does NOT initiate association in
115-
// esp-radio 0.18 — `connect_async` must always be called explicitly.
101+
// Credentials were configured in `WiFiManager::init_async`; calling
102+
// `connect_async` directly reuses those settings without resetting driver state.
116103
#[embassy_executor::task]
117104
async fn wifi_task(mut controller: WifiController<'static>) {
118105
loop {
@@ -121,12 +108,15 @@ async fn wifi_task(mut controller: WifiController<'static>) {
121108
// Connected — block until the link drops.
122109
let _ = controller.wait_for_disconnect_async().await;
123110
println!("Wi-Fi disconnected — reconnecting...");
111+
// Short delay: we know the AP exists, reconnect promptly.
112+
Timer::after(Duration::from_millis(500)).await;
124113
}
125114
Err(e) => {
126115
println!("connect failed: {:?}", e);
116+
// Longer backoff: AP may be unreachable or credentials wrong.
117+
Timer::after(Duration::from_millis(5000)).await;
127118
}
128119
}
129-
Timer::after(Duration::from_millis(500)).await;
130120
}
131121
}
132122

crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async_led.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ async fn led_task(mut led: Output<'static>) {
149149
}
150150

151151
// Handles both the initial association and subsequent reconnects.
152+
// Credentials were configured in `WiFiManager::init_async`; calling
153+
// `connect_async` directly reuses those settings without resetting driver state.
152154
// The LED `CONNECTED` flag is owned by `link_status_task` watching
153155
// `embassy_net::Stack` config-up/config-down edges — not toggled here,
154156
// because a successful L2 reconnect does not yet imply a new DHCP lease.
@@ -159,12 +161,13 @@ async fn wifi_task(mut controller: WifiController<'static>) {
159161
Ok(_) => {
160162
let _ = controller.wait_for_disconnect_async().await;
161163
println!("Wi-Fi disconnected — reconnecting...");
164+
Timer::after(Duration::from_millis(500)).await;
162165
}
163166
Err(e) => {
164167
println!("connect failed: {:?}", e);
168+
Timer::after(Duration::from_millis(5000)).await;
165169
}
166170
}
167-
Timer::after(Duration::from_millis(500)).await;
168171
}
169172
}
170173

crates/rustyfarian-esp-hal-wifi/examples/hal_c6_connect_async_led.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ async fn led_task(mut led: Ws2812Rmt<'static, Blocking, N>) {
174174
}
175175

176176
// Handles both the initial association and subsequent reconnects.
177+
// Credentials were configured in `WiFiManager::init_async`; calling
178+
// `connect_async` directly reuses those settings without resetting driver state.
177179
// The LED `CONNECTED` flag is owned by `link_status_task` watching
178180
// `embassy_net::Stack` config-up/config-down edges — not toggled here,
179181
// because a successful L2 reconnect does not yet imply a new DHCP lease.
@@ -184,12 +186,13 @@ async fn wifi_task(mut controller: WifiController<'static>) {
184186
Ok(_) => {
185187
let _ = controller.wait_for_disconnect_async().await;
186188
println!("Wi-Fi disconnected — reconnecting...");
189+
Timer::after(Duration::from_millis(500)).await;
187190
}
188191
Err(e) => {
189192
println!("connect failed: {:?}", e);
193+
Timer::after(Duration::from_millis(5000)).await;
190194
}
191195
}
192-
Timer::after(Duration::from_millis(500)).await;
193196
}
194197
}
195198

crates/rustyfarian-esp-hal-wifi/src/lib.rs

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -261,38 +261,57 @@ mod driver {
261261
let sw_ints = SoftwareInterruptControl::new(config.sw_interrupt);
262262
esp_rtos::start(timg.timer0, sw_ints.software_interrupt0);
263263

264-
// 2. Build the station config and pass it as `initial_config` so that
265-
// `esp_wifi_set_config` is called with real credentials BEFORE
266-
// `esp_wifi_start()` fires inside `wifi::new`. Calling
267-
// `set_config` a second time after start (the previous approach)
268-
// does not reliably propagate credentials to the firmware on
269-
// bare-metal — the IDF driver always sets config before start.
264+
// 2. Construct the Wi-Fi controller with a default ControllerConfig
265+
// (empty station config). Credentials are applied via an explicit
266+
// `set_config` call immediately after `wifi::new` returns (step 3).
267+
let (mut controller, interfaces) =
268+
esp_radio::wifi::new(config.wifi, ControllerConfig::default())
269+
.map_err(WifiError::Driver)?;
270+
271+
// 3. Apply station credentials. esp_radio::wifi::new already called
272+
// set_config internally with an empty StationConfig (which starts the
273+
// radio driver via esp_wifi_start). This call updates the SSID/password
274+
// so wifi_task's first connect_async uses the real credentials.
270275
let station = StationConfig::default()
271276
.with_ssid(config.ssid)
272277
.with_password(config.password.into());
273-
let controller_cfg =
274-
ControllerConfig::default().with_initial_config(Config::Station(station));
275-
276-
// 3. Construct the Wi-Fi controller. `wifi::new` applies
277-
// `initial_config` (our real credentials) then calls
278-
// `esp_wifi_start()` — credentials are set before the radio
279-
// starts, matching the IDF init sequence.
280-
let (mut controller, interfaces) =
281-
esp_radio::wifi::new(config.wifi, controller_cfg).map_err(WifiError::Driver)?;
282-
283-
// 4. Power save (non-fatal if it fails).
278+
controller
279+
.set_config(&Config::Station(station))
280+
.map_err(WifiError::Driver)?;
281+
282+
// 4. Limit TX power to 8.5 dBm (34 × 0.25 dBm).
283+
//
284+
// ESP32-C3/C6 Super Mini and similar PCB-antenna boards reflect RF energy
285+
// back into the chip at full power (~20 dBm), corrupting WPA2 auth frames
286+
// and causing every AP to deauth with reason 2 (AuthenticationExpired).
287+
// ESP-IDF limits TX power internally for regulatory compliance; the
288+
// bare-metal blob does not. This call must come after set_config() (step 3)
289+
// because that is what triggers esp_wifi_start() — calling it before returns
290+
// ESP_ERR_WIFI_NOT_STARTED (0x3002).
291+
//
292+
// The symbol is already in the linked binary via esp-radio's dependency on
293+
// esp-wifi-sys; no extra crate dependency is needed.
294+
//
295+
// Upstream: esp-rs/esp-hal #3488, espressif/arduino-esp32 #6767.
296+
extern "C" {
297+
fn esp_wifi_set_max_tx_power(power: i8) -> i32;
298+
}
299+
// Default to Low (8.5 dBm) if the caller left tx_power at Medium (the
300+
// wifi_pure default). Medium (~13 dBm) still causes auth failures on
301+
// PCB-antenna boards; Low is the safe baseline for bare-metal.
302+
let quarter_dbm = if config.tx_power == TxPowerLevel::default() {
303+
TxPowerLevel::Low.to_quarter_dbm()
304+
} else {
305+
config.tx_power.to_quarter_dbm()
306+
};
307+
unsafe { esp_wifi_set_max_tx_power(quarter_dbm) };
308+
309+
// 5. Power save (non-fatal if it fails).
284310
let ps = map_power_save(config.power_save);
285311
if let Err(e) = controller.set_power_saving(ps) {
286312
log::warn!("Failed to set power save mode (non-fatal): {:?}", e);
287313
}
288314

289-
if config.tx_power != TxPowerLevel::default() {
290-
log::warn!(
291-
"TX power level {:?} configured but esp-radio 0.18 does not expose tx_power API — using radio default",
292-
config.tx_power
293-
);
294-
}
295-
296315
if config.password.is_empty() {
297316
log::warn!(
298317
"Wi-Fi password is empty — auth will fail on WPA2/WPA3 networks; \

docs/features/hal-c3-connect-async-example-v1.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,87 @@ Source: `docs/embassy-integration-research.md` — example code sketch under Opt
6363
- 2026-04-08 — Feature doc created from `docs/embassy-integration-research.md`
6464
- 2026-04-08 — Implemented: `examples/hal_c3_connect_async.rs` using `#[esp_rtos::main]` with two spawned tasks (`wifi_task` + `net_task`). Destructures `AsyncWifiHandle` (stack is `Copy`, keeps main's reference while moving controller/runner into tasks). `esp_alloc::heap_allocator!(size: 72 * 1024)` — single-region on C3. `WiFiManager::init_async` internally calls `esp_rtos::start()` via `init_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.sh` grew a `*_async*` case that appends the `embassy` feature automatically, mirroring the existing `*_rgb*` pattern. `just fmt`, `just verify`, and `just build-example hal_c3_connect_async` all pass clean.
6565
- 2026-04-10 — Fixed `scripts/flash.sh` missing the `*_async*``embassy` feature detection that `build-example.sh` already had. Hardware validation on real ESP32-C3: build, flash, Wi-Fi connect, and DHCP lease all confirmed working. AP reconnect loop test still pending.
66+
67+
---
68+
69+
## Debugging Session: AuthenticationExpired on WPA2 AP (2026-05-15)
70+
71+
### Environment
72+
73+
- Hardware: ESP32-C3 Super Mini
74+
- Network: WPA2 AP with two virtual SSIDs on the same physical radio
75+
- Crate stack: `rustyfarian-esp-hal-wifi` v0.2.1, `esp-radio 0.18.0`, `esp-rtos 0.2.0`
76+
- Reference: `rustyfarian-esp-idf-wifi` using `esp-idf-svc` connects without issue on the same C3 board
77+
78+
### Symptom
79+
80+
`connect_async()` always fails with:
81+
82+
```
83+
connect failed: Disconnected(DisconnectedStationInfo {
84+
ssid: "<ssid>",
85+
reason: AuthenticationExpired,
86+
rssi: -33
87+
})
88+
```
89+
90+
`WIFI_REASON_AUTH_EXPIRE` = reason code 2.
91+
The AP sends a Deauthentication frame before the WPA2 4-way handshake completes.
92+
Signal strength is excellent (-33 to -40 dBm) — not a range issue.
93+
94+
### Key facts established
95+
96+
- Two virtual SSIDs on the same physical AP; same channel 11.
97+
- Secondary SSID appeared in `scan_async` results; primary target SSID did NOT appear in the scan despite excellent signal.
98+
- The ESP-IDF C stack is used by both IDF and esp-radio; differences are in how they call it.
99+
- esp-radio 0.18.0 `wifi_init_config_t` has `nvs_enable: 0` — NVS (PMKSA cache) is disabled.
100+
- `apply_sta_config` in esp-radio sets `pmf_cfg: { capable: true, required: false }` (hardcoded, not configurable via `StationConfig`).
101+
- `StationConfig::default()` fields: `auth_method: Wpa2Personal`, `failure_retry_cnt: 1`, `beacon_timeout: 6`, `scan_method: Fast`.
102+
- These map to identical `wifi_sta_config_t` C fields as `esp-idf-svc`'s `ClientConfiguration::default()` — no difference found at the C config level.
103+
104+
### Failed attempt 1 — set_config before every connect_async
105+
106+
**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.
107+
108+
**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`.
109+
110+
**Result (hardware log):**
111+
```
112+
Scanning...
113+
AccessPointInfo { ssid: "<secondary-ssid>", channel: 11,
114+
signal_strength: -73, auth_method: Some(Wpa2Personal), ... }
115+
Waiting for DHCPv4 lease...
116+
connect failed: Disconnected(..., reason: AuthenticationExpired, rssi: -40)
117+
```
118+
119+
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.
120+
121+
### Failed attempt 2 — remove scan_async before connecting
122+
123+
**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.
124+
125+
**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`.
126+
127+
**Result (hardware log):**
128+
```
129+
Initializing Wi-Fi (async)...
130+
INFO - Wi-Fi configured, power save: None
131+
Waiting for DHCPv4 lease...
132+
connect failed: Disconnected(..., reason: AuthenticationExpired, rssi: -33)
133+
connect failed: Disconnected(..., reason: AuthenticationExpired, rssi: -33)
134+
```
135+
136+
Scan removal made no difference. **Hypothesis disproved.** The scan was not interfering with auth timing.
137+
138+
### Resolution — TX power (2026-05-18)
139+
140+
**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.
141+
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`.
142+
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`.
143+
144+
### Current state of the code (as of 2026-05-18)
145+
146+
- No `scan_async` in `hal_c3_connect_async.rs` (removed; the IDF variant never scanned either).
147+
- `wifi_task` calls `set_config` before every `connect_async` — retained as defensive practice.
148+
- `lib.rs` `init_async` calls `esp_wifi_set_max_tx_power(34)` immediately after `set_config`.
149+
- `StationConfig`: `Wpa2Personal`, no explicit BSSID, no explicit channel.

docs/project-lore.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,14 @@ The password setter still needs `.into()` because its parameter type is the more
120120
The pin moved from the `configure_tx` parameter to a chained `.with_pin(...)` call so that channel configuration can be reused independently of pin assignment.
121121
The reference migration is in the `rustyfarian-ws2812` repo's CHANGELOG entry for the April 2026 wave (file: `crates/rustyfarian-esp-hal-ws2812/examples/hal_c6_*.rs`).
122122

123+
**ESP32-C3 bare-metal Wi-Fi (esp-radio 0.18) fails with `AuthenticationExpired` (reason 2) on every WPA2 AP because the binary blob transmits at full power (~20 dBm), which the Super Mini PCB antenna reflects back into the chip and corrupts auth frames.**
124+
ESP-IDF limits TX power internally for regulatory compliance; the bare-metal blob does not.
125+
The same hardware and credentials connect fine under the ESP-IDF std stack.
126+
Fix: declare `esp_wifi_set_max_tx_power` via `extern "C"` (the symbol is already linked transitively via `esp-radio`) and call it immediately after `controller.set_config()``set_config` triggers `esp_wifi_start()` internally, and the call must come *after* that; calling it before returns `ESP_ERR_WIFI_NOT_STARTED` (error 12290 / `0x3002`).
127+
Use `esp_wifi_set_max_tx_power(34)` (34 × 0.25 dBm = 8.5 dBm).
128+
Workaround lives in `WiFiManager::init_async` (`crates/rustyfarian-esp-hal-wifi/src/lib.rs`) and in `hal_c3_connect_async_upstream.rs` for the upstream-verbatim example.
129+
Upstream references: esp-rs/esp-hal #3488, espressif/arduino-esp32 #6767.
130+
123131
**`embassy-executor 0.10` removed `Spawner::must_spawn`; `#[embassy_executor::task]` macros now return `Result<SpawnToken<...>, SpawnError>`.**
124132
Old: `spawner.must_spawn(my_task(arg));`
125133
New: `spawner.spawn(my_task(arg).unwrap());` — the `.unwrap()` goes on the **task call** (which returns `Result`), not on `spawner.spawn` (which returns `()`).

0 commit comments

Comments
 (0)