diff --git a/crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async.rs b/crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async.rs index e710d4e..8dae667 100644 --- a/crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async.rs +++ b/crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async.rs @@ -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 @@ -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 => "", @@ -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()); @@ -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 { @@ -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; } } diff --git a/crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async_led.rs b/crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async_led.rs index b7312f1..5a1fbae 100644 --- a/crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async_led.rs +++ b/crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async_led.rs @@ -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 => "", @@ -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. @@ -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; } } diff --git a/crates/rustyfarian-esp-hal-wifi/examples/hal_c6_connect_async_led.rs b/crates/rustyfarian-esp-hal-wifi/examples/hal_c6_connect_async_led.rs index 3c78ab2..26d6b60 100644 --- a/crates/rustyfarian-esp-hal-wifi/examples/hal_c6_connect_async_led.rs +++ b/crates/rustyfarian-esp-hal-wifi/examples/hal_c6_connect_async_led.rs @@ -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 => "", @@ -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. @@ -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; } } diff --git a/crates/rustyfarian-esp-hal-wifi/src/lib.rs b/crates/rustyfarian-esp-hal-wifi/src/lib.rs index ca84947..9260e9b 100644 --- a/crates/rustyfarian-esp-hal-wifi/src/lib.rs +++ b/crates/rustyfarian-esp-hal-wifi/src/lib.rs @@ -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 @@ -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; \ diff --git a/docs/features/hal-c3-connect-async-example-v1.md b/docs/features/hal-c3-connect-async-example-v1.md index 3f9a6f5..cc9be72 100644 --- a/docs/features/hal-c3-connect-async-example-v1.md +++ b/docs/features/hal-c3-connect-async-example-v1.md @@ -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: "", + 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: "", 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. diff --git a/docs/project-lore.md b/docs/project-lore.md index f6b53ec..bb0195e 100644 --- a/docs/project-lore.md +++ b/docs/project-lore.md @@ -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, 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 `()`).