Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 14 additions & 20 deletions crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
//!
//! Two tasks are spawned alongside the main task:
//!
//! * `wifi_task` — owns the [`WifiController`] and keeps the station
//! associated, reconnecting after any `StaDisconnected` event.
//! * `wifi_task` — owns the [`WifiController`] and reconnects after any
//! `StaDisconnected` event. Credentials are applied once in
//! [`WiFiManager::init_async`]; `connect_async` reuses them on every attempt.
//! * `net_task` — drives the `embassy-net` stack by calling `runner.run()`.
//!
//! `WIFI_SSID` and `WIFI_PASS` must be set as environment variables **at build
Expand All @@ -30,11 +31,14 @@ use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_backtrace as _;
use esp_println::println;
use esp_radio::wifi::{scan::ScanConfig, Interface, WifiController};
use esp_radio::wifi::{Interface, WifiController};
use rustyfarian_esp_hal_wifi::{AsyncWifiHandle, WiFiConfig, WiFiConfigExt, WiFiManager};

esp_bootloader_esp_idf::esp_app_desc!();

const RECONNECT_DELAY_MS: u64 = 500;
const CONNECT_BACKOFF_MS: u64 = 5000;

const SSID: &str = match option_env!("WIFI_SSID") {
Some(s) => s,
None => "",
Expand Down Expand Up @@ -73,24 +77,11 @@ async fn main(spawner: Spawner) {
// Destructure: `stack` is `Copy`, so we keep our own copy before moving
// `controller` and `runner` into their tasks.
let AsyncWifiHandle {
mut controller,
controller,
stack,
runner,
} = handle;

// Scan before connecting — mirrors the official embassy_dhcp example.
// The active scan lets the radio settle and builds its BSSID/channel cache
// before the first association attempt.
println!("Scanning...");
match controller.scan_async(&ScanConfig::default()).await {
Ok(aps) => {
for ap in &aps {
println!(" {:?}", ap);
}
}
Err(e) => println!("Scan failed (continuing anyway): {:?}", e),
}

spawner.spawn(wifi_task(controller).unwrap());
spawner.spawn(net_task(runner).unwrap());

Expand All @@ -111,8 +102,8 @@ async fn main(spawner: Spawner) {
}

// Handles both the initial association and any subsequent reconnects.
// `set_config` starts the radio but does NOT initiate association in
// esp-radio 0.18 — `connect_async` must always be called explicitly.
// Credentials were configured in `WiFiManager::init_async`; calling
// `connect_async` directly reuses those settings without resetting driver state.
#[embassy_executor::task]
async fn wifi_task(mut controller: WifiController<'static>) {
loop {
Expand All @@ -121,12 +112,15 @@ async fn wifi_task(mut controller: WifiController<'static>) {
// Connected — block until the link drops.
let _ = controller.wait_for_disconnect_async().await;
println!("Wi-Fi disconnected — reconnecting...");
// Short delay: we know the AP exists, reconnect promptly.
Timer::after(Duration::from_millis(RECONNECT_DELAY_MS)).await;
}
Err(e) => {
println!("connect failed: {:?}", e);
// Longer backoff: AP may be unreachable or credentials wrong.
Timer::after(Duration::from_millis(CONNECT_BACKOFF_MS)).await;
}
}
Timer::after(Duration::from_millis(500)).await;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ use rustyfarian_esp_hal_wifi::{AsyncWifiHandle, WiFiConfig, WiFiConfigExt, WiFiM

esp_bootloader_esp_idf::esp_app_desc!();

const RECONNECT_DELAY_MS: u64 = 500;
const CONNECT_BACKOFF_MS: u64 = 5000;

const SSID: &str = match option_env!("WIFI_SSID") {
Some(s) => s,
None => "",
Expand Down Expand Up @@ -149,6 +152,8 @@ async fn led_task(mut led: Output<'static>) {
}

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ use rustyfarian_esp_hal_ws2812::{buffer_size, Ws2812Rmt, RMT_CLK_DIV};

esp_bootloader_esp_idf::esp_app_desc!();

const RECONNECT_DELAY_MS: u64 = 500;
const CONNECT_BACKOFF_MS: u64 = 5000;

const SSID: &str = match option_env!("WIFI_SSID") {
Some(s) => s,
None => "",
Expand Down Expand Up @@ -174,6 +177,8 @@ async fn led_task(mut led: Ws2812Rmt<'static, Blocking, N>) {
}

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

Expand Down
88 changes: 65 additions & 23 deletions crates/rustyfarian-esp-hal-wifi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,18 @@ mod driver {
/// );
/// ```
///
/// # TX-power policy
///
/// When `tx_power` is left at [`TxPowerLevel::Medium`] (the default),
/// `init_async` silently overrides it to [`TxPowerLevel::Low`] (8.5 dBm).
///
/// This is a deliberate library-wide default: PCB-antenna boards such as
/// the ESP32-C3/C6 Super Mini reflect RF energy at full power (~20 dBm),
/// corrupting WPA2 auth frames. 8.5 dBm is a safe baseline for all
/// bare-metal builds; callers with external antennas can raise it by
/// passing an explicit [`TxPowerLevel`] via
/// [`WiFiConfig::with_tx_power`][wifi_pure::WiFiConfig::with_tx_power].
///
/// # One-shot
///
/// Call at most once per boot — a `static` `StackResources` is
Expand All @@ -261,38 +273,68 @@ mod driver {
let sw_ints = SoftwareInterruptControl::new(config.sw_interrupt);
esp_rtos::start(timg.timer0, sw_ints.software_interrupt0);

// 2. Build the station config and pass it as `initial_config` so that
// `esp_wifi_set_config` is called with real credentials BEFORE
// `esp_wifi_start()` fires inside `wifi::new`. Calling
// `set_config` a second time after start (the previous approach)
// does not reliably propagate credentials to the firmware on
// bare-metal — the IDF driver always sets config before start.
// 2. Construct the Wi-Fi controller with a default ControllerConfig
// (empty station config). Credentials are applied via an explicit
// `set_config` call immediately after `wifi::new` returns (step 3).
let (mut controller, interfaces) =
esp_radio::wifi::new(config.wifi, ControllerConfig::default())
.map_err(WifiError::Driver)?;

// 3. Apply station credentials. esp_radio::wifi::new already called
// set_config internally with an empty StationConfig (which starts the
// radio driver via esp_wifi_start). This call updates the SSID/password
// so wifi_task's first connect_async uses the real credentials.
let station = StationConfig::default()
.with_ssid(config.ssid)
.with_password(config.password.into());
let controller_cfg =
ControllerConfig::default().with_initial_config(Config::Station(station));

// 3. Construct the Wi-Fi controller. `wifi::new` applies
// `initial_config` (our real credentials) then calls
// `esp_wifi_start()` — credentials are set before the radio
// starts, matching the IDF init sequence.
let (mut controller, interfaces) =
esp_radio::wifi::new(config.wifi, controller_cfg).map_err(WifiError::Driver)?;
controller
.set_config(&Config::Station(station))
.map_err(WifiError::Driver)?;

// 4. Limit TX power to 8.5 dBm (34 × 0.25 dBm).
//
// ESP32-C3/C6 Super Mini and similar PCB-antenna boards reflect RF energy
// back into the chip at full power (~20 dBm), corrupting WPA2 auth frames
// and causing every AP to deauth with reason 2 (AuthenticationExpired).
// ESP-IDF limits TX power internally for regulatory compliance; the
// bare-metal blob does not. This call must come after set_config() (step 3)
// because that is what triggers esp_wifi_start() — calling it before returns
// ESP_ERR_WIFI_NOT_STARTED (0x3002).
//
// The symbol is already in the linked binary via esp-radio's dependency on
// esp-wifi-sys; no extra crate dependency is needed.
//
// Upstream: esp-rs/esp-hal #3488, espressif/arduino-esp32 #6767.
extern "C" {
fn esp_wifi_set_max_tx_power(power: i8) -> i32;
}
// Default to Low (8.5 dBm) if the caller left tx_power at Medium (the
// wifi_pure default). Medium (~13 dBm) still causes auth failures on
// PCB-antenna boards; Low is the safe baseline for bare-metal.
let quarter_dbm = if config.tx_power == TxPowerLevel::default() {
TxPowerLevel::Low.to_quarter_dbm()
} else {
config.tx_power.to_quarter_dbm()
};
// SAFETY: `esp_wifi_set_max_tx_power` is provided by esp-wifi-sys (linked
// by every esp-radio build); it is only valid after esp_wifi_start(), which
// step 3's set_config() triggers. `to_quarter_dbm()` returns values in
// [8, 78] — all within the SDK-documented valid range [8, 84].
let rc = unsafe { esp_wifi_set_max_tx_power(quarter_dbm) };
if rc != 0 {
log::warn!(
"esp_wifi_set_max_tx_power({}) failed with code {:#010x} (non-fatal)",
quarter_dbm,
rc
);
}

// 4. Power save (non-fatal if it fails).
// 5. Power save (non-fatal if it fails).
let ps = map_power_save(config.power_save);
if let Err(e) = controller.set_power_saving(ps) {
log::warn!("Failed to set power save mode (non-fatal): {:?}", e);
}

if config.tx_power != TxPowerLevel::default() {
log::warn!(
"TX power level {:?} configured but esp-radio 0.18 does not expose tx_power API — using radio default",
config.tx_power
);
}

if config.password.is_empty() {
log::warn!(
"Wi-Fi password is empty — auth will fail on WPA2/WPA3 networks; \
Expand Down
84 changes: 84 additions & 0 deletions docs/features/hal-c3-connect-async-example-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,87 @@ Source: `docs/embassy-integration-research.md` — example code sketch under Opt
- 2026-04-08 — Feature doc created from `docs/embassy-integration-research.md`
- 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.
- 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.

---

## Debugging Session: AuthenticationExpired on WPA2 AP (2026-05-15)

### Environment

- Hardware: ESP32-C3 Super Mini
- Network: WPA2 AP with two virtual SSIDs on the same physical radio
- Crate stack: `rustyfarian-esp-hal-wifi` v0.2.1, `esp-radio 0.18.0`, `esp-rtos 0.2.0`
- Reference: `rustyfarian-esp-idf-wifi` using `esp-idf-svc` connects without issue on the same C3 board

### Symptom

`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.

### Key facts established

- Two virtual SSIDs on the same physical AP; same channel 11.
- Secondary SSID appeared in `scan_async` results; 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_t` has `nvs_enable: 0` — NVS (PMKSA cache) is disabled.
- `apply_sta_config` in esp-radio sets `pmf_cfg: { capable: true, required: false }` (hardcoded, not configurable via `StationConfig`).
- `StationConfig::default()` fields: `auth_method: Wpa2Personal`, `failure_retry_cnt: 1`, `beacon_timeout: 6`, `scan_method: Fast`.
- 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.

### Failed attempt 1 — set_config before every connect_async

**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.

### Failed attempt 2 — remove scan_async before connecting

**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.

### Resolution — TX power (2026-05-18)

**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`.

### Current state of the code (as of 2026-05-18)

- No `scan_async` in `hal_c3_connect_async.rs` (removed; the IDF variant never scanned either).
- `wifi_task` calls `set_config` before every `connect_async` — retained as defensive practice.
- `lib.rs` `init_async` calls `esp_wifi_set_max_tx_power(34)` immediately after `set_config`.
- `StationConfig`: `Wpa2Personal`, no explicit BSSID, no explicit channel.
8 changes: 8 additions & 0 deletions docs/project-lore.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ The password setter still needs `.into()` because its parameter type is the more
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.
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`).

**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.**
ESP-IDF limits TX power internally for regulatory compliance; the bare-metal blob does not.
The same hardware and credentials connect fine under the ESP-IDF std stack.
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`).
Use `esp_wifi_set_max_tx_power(34)` (34 × 0.25 dBm = 8.5 dBm).
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.
Upstream references: esp-rs/esp-hal #3488, espressif/arduino-esp32 #6767.

**`embassy-executor 0.10` removed `Spawner::must_spawn`; `#[embassy_executor::task]` macros now return `Result<SpawnToken<...>, SpawnError>`.**
Old: `spawner.must_spawn(my_task(arg));`
New: `spawner.spawn(my_task(arg).unwrap());` — the `.unwrap()` goes on the **task call** (which returns `Result`), not on `spawner.spawn` (which returns `()`).
Expand Down
Loading