diff --git a/CHANGELOG.md b/CHANGELOG.md index 25258cd..510a043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `rustyfarian-esp-hal-wifi`: opt-in `embassy` Cargo feature that activates the embassy ecosystem (`embassy-executor 0.9`, `embassy-net 0.7`, `embassy-time 0.5`, `static_cell 2.1`, `embedded-io-async 0.6`) and `esp-rtos/embassy` — foundation for async Wi-Fi support (see `docs/features/embassy-feature-flag-v1.md`); off by default, no behavioural change when unused +- `rustyfarian-esp-hal-wifi`: `WiFiManager::init_async()` async companion API behind the `embassy` feature — returns an `AsyncWifiHandle { controller, stack, runner }` wired into an `embassy-net` stack with automatic DHCPv4, replacing the manual `smoltcp` poll loop used by the blocking path; `AsyncWifiHandle::wait_for_ip().await` mirrors the blocking `wait_connected()` convenience (see `docs/features/wifi-manager-async-v1.md`) +- `rustyfarian-esp-hal-wifi`: `hal_c3_connect_async` example — first async bare-metal Wi-Fi demo on ESP32-C3, uses `#[esp_rtos::main]` with two spawned tasks (`wifi_task` for association + reconnection, `net_task` for the embassy-net runner), prints the DHCP-assigned IP and idles asynchronously (see `docs/features/hal-c3-connect-async-example-v1.md`) +- Build scripts: `scripts/build-example.sh` now appends the `embassy` feature automatically for any `hal_*_async*` example +- Justfile: `check-wifi-hal-embassy` recipe that verifies the `embassy` feature compiles for ESP32-C6 (`riscv32imac-unknown-none-elf`) and ESP32-C3 (`riscv32imc-unknown-none-elf`) - `espnow-pure`: `PeerTracker` — heartbeat-based peer liveness tracker with online/offline transition detection, extracted from rustbox-rgb-puzzle brain firmware - `espnow-pure`: `ScanConfig::with_probe_timeout()` and `DEFAULT_PROBE_TIMEOUT` (100 ms) — per-channel probe timeout is now configurable - `espnow-pure`: `ScanConfig::with_burst_timeout()` and `DEFAULT_BURST_TIMEOUT` (3 s) — bounds total time the radio spends at boosted TX power during peer discovery @@ -21,6 +26,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `rustyfarian-esp-hal-wifi`: stores `TxPowerLevel` config; logs warning that `esp-radio 0.17` does not expose TX power API - `rustyfarian-esp-idf-espnow`: `scan_for_peer()` auto-bursts TX power to maximum during channel scanning, restores previous level after scan completes - `espnow-pure`: `command` module — `CommandFrame<'a>` zero-copy parser, `SystemCommand` enum (`Ping`, `SelfTest`, `Identify`), tag range helpers, and response payload builders for the ESP-NOW Peripheral Command Framework (see `docs/features/espnow-peripheral-command-framework-v1.md`) +- `rustyfarian-esp-hal-wifi`: `ActiveLowLed

` adapter — implements `StatusLed` with inverted polarity for onboard LEDs wired active-low (e.g. ESP32-C3 Super Mini GPIO8) +- `rustyfarian-esp-hal-wifi`: `hal_c3_connect_async_led` example — async Wi-Fi connect with spawned `led_task` that blinks the onboard GPIO8 LED during connection, holds steady once IP acquired; uses `AtomicBool` for task coordination +- `rustyfarian-esp-hal-wifi`: `hal_c6_connect_async_led` example — async Wi-Fi connect with spawned `led_task` that pulses the onboard WS2812 RGB LED (GPIO8) blue via `PulseEffect` during connection, holds dim green once connected +- Build scripts: `build-example.sh` and `flash.sh` auto-detect `rustyfarian-esp-hal-ws2812` feature for `hal_c6_*_led*` examples - Justfile: `check-wifi-hal-embassy` recipe that verifies the `embassy` feature compiles for ESP32-C6 (`riscv32imac-unknown-none-elf`) and ESP32-C3 (`riscv32imc-unknown-none-elf`) - `rustyfarian-esp-hal-wifi`: `EspHalWifiManager` with real `WifiDriver` implementation using `esp-radio 0.17.0` for bare-metal ESP32-C3/C6 (ADR 006 Phase 5); `hal_c3_connect` and `hal_c6_connect` examples - `rustyfarian-network-pure`: `status_colors` module with shared LED colour palette (`BOOT`, `WIFI_CONNECTING`, `MQTT_CONNECTING`, `CONNECTED`, `ERROR`, `OFFLINE`) diff --git a/Cargo.toml b/Cargo.toml index d3f0d80..4f1794d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,13 @@ esp-radio = { version = "0.17", default-features = false } esp-rtos = { version = "0.2", default-features = false } esp-alloc = { version = "0.9" } +# Embassy async ecosystem (opt-in via the `embassy` feature on HAL crates) +embassy-executor = { version = "0.9", default-features = false } +embassy-net = { version = "0.7", default-features = false } +embassy-time = { version = "0.5", default-features = false } +static_cell = { version = "2.1" } +embedded-io-async = { version = "0.6" } + # Bare-metal debugging esp-backtrace = { version = "0.18", default-features = false, features = ["panic-handler", "println"] } diff --git a/crates/rustyfarian-esp-hal-wifi/Cargo.toml b/crates/rustyfarian-esp-hal-wifi/Cargo.toml index 002040f..08e1fb3 100644 --- a/crates/rustyfarian-esp-hal-wifi/Cargo.toml +++ b/crates/rustyfarian-esp-hal-wifi/Cargo.toml @@ -35,9 +35,21 @@ esp32c3 = [ ] unstable = ["esp-hal/unstable"] rt = ["esp-hal/rt"] +# Opt-in async support via the embassy ecosystem. Orthogonal to chip selection — +# enable alongside `esp32c6` or `esp32c3`. Activates `esp-rtos/embassy` which +# supplies the embassy-time driver and executor wiring. +embassy = [ + "dep:embassy-executor", + "dep:embassy-net", + "dep:embassy-time", + "dep:static_cell", + "dep:embedded-io-async", + "esp-rtos?/embassy", +] [dependencies] wifi-pure.workspace = true +embedded-hal.workspace = true led-effects.workspace = true rgb.workspace = true log.workspace = true @@ -51,6 +63,15 @@ esp-bootloader-esp-idf = { version = "0.4.0", default-features = false, optional esp-println = { version = "0.16", default-features = false, optional = true } rustyfarian-esp-hal-ws2812 = { workspace = true, optional = true } +# Embassy (optional — activated by the `embassy` feature) +embassy-executor = { workspace = true, optional = true } +embassy-net = { workspace = true, optional = true, features = ["dhcpv4", "proto-ipv4", "medium-ethernet"] } +embassy-time = { workspace = true, optional = true } +static_cell = { workspace = true, optional = true } +# Re-exported via the `embassy` feature for downstream socket code that needs +# `AsyncRead` / `AsyncWrite`. Not used inside this crate yet. +embedded-io-async = { workspace = true, optional = true } + [[example]] name = "hal_c6_connect" required-features = ["esp32c6", "rt"] @@ -59,14 +80,26 @@ required-features = ["esp32c6", "rt"] name = "hal_c3_connect" required-features = ["esp32c3", "rt"] +[[example]] +name = "hal_c3_connect_async" +required-features = ["esp32c3", "rt", "embassy"] + [[example]] name = "hal_c6_wifi_raw" required-features = ["esp32c6", "rt"] +[[example]] +name = "hal_c3_connect_async_led" +required-features = ["esp32c3", "rt", "embassy"] + [[example]] name = "hal_c3_wifi_raw" required-features = ["esp32c3", "rt"] +[[example]] +name = "hal_c6_connect_async_led" +required-features = ["esp32c6", "rt", "embassy", "rustyfarian-esp-hal-ws2812"] + [[example]] name = "hal_c6_connect_nonblocking_rgb" required-features = ["esp32c6", "rt", "rustyfarian-esp-hal-ws2812"] diff --git a/crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect.rs b/crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect.rs index 4cef859..f9cb844 100644 --- a/crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect.rs +++ b/crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect.rs @@ -36,6 +36,9 @@ const PASSWORD: &str = match option_env!("WIFI_PASS") { fn run() -> Result<(), WifiError> { let peripherals = esp_hal::init(esp_hal::Config::default()); + // ESP32-C3 has contiguous SRAM — a single 72 KiB heap region is sufficient. + esp_alloc::heap_allocator!(size: 72 * 1024); + let config = WiFiConfig::new(SSID, PASSWORD).with_peripherals( peripherals.TIMG0, peripherals.SW_INTERRUPT, 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 new file mode 100644 index 0000000..781af04 --- /dev/null +++ b/crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async.rs @@ -0,0 +1,117 @@ +//! Bare-metal async Wi-Fi connect example for ESP32-C3 Super Mini. +//! +//! Demonstrates [`WiFiManager::init_async`] connecting to a WPA2 access point +//! using `esp-radio` on top of an `embassy-net` stack. +//! DHCPv4 is handled by the stack; the application prints the assigned IP and +//! then idles asynchronously. +//! +//! Two tasks are spawned alongside the main task: +//! +//! * `wifi_task` — owns the [`WifiController`] and keeps the station +//! associated, reconnecting after any `StaDisconnected` event. +//! * `net_task` — drives the `embassy-net` stack by calling `runner.run()`. +//! +//! `WIFI_SSID` and `WIFI_PASS` must be set as environment variables **at build +//! time**. The example requires the `embassy` Cargo feature. +//! +//! # Build and flash +//! +//! ```sh +//! WIFI_SSID="MyNetwork" WIFI_PASS="secret" just build-example hal_c3_connect_async +//! just flash hal_c3_connect_async +//! ``` + +#![no_std] +#![no_main] + +extern crate alloc; + +use embassy_executor::Spawner; +use embassy_time::{Duration, Timer}; +use esp_backtrace as _; +use esp_println::println; +use esp_radio::wifi::{WifiController, WifiDevice, WifiEvent}; +use rustyfarian_esp_hal_wifi::{AsyncWifiHandle, WiFiConfig, WiFiConfigExt, WiFiManager}; + +esp_bootloader_esp_idf::esp_app_desc!(); + +const SSID: &str = match option_env!("WIFI_SSID") { + Some(s) => s, + None => "", +}; +const PASSWORD: &str = match option_env!("WIFI_PASS") { + Some(s) => s, + None => "", +}; + +#[esp_rtos::main] +async fn main(spawner: Spawner) { + esp_println::logger::init_logger(log::LevelFilter::Info); + + let peripherals = esp_hal::init(esp_hal::Config::default()); + + // ESP32-C3 has contiguous SRAM — a single 72 KiB region is sufficient for + // the Wi-Fi radio buffers and general-purpose allocations. + esp_alloc::heap_allocator!(size: 72 * 1024); + + println!("Initializing Wi-Fi (async)..."); + + let config = WiFiConfig::new(SSID, PASSWORD).with_peripherals( + peripherals.TIMG0, + peripherals.SW_INTERRUPT, + peripherals.WIFI, + ); + + let handle = match WiFiManager::init_async(config) { + Ok(h) => h, + Err(e) => { + println!("FATAL: Wi-Fi init failed: {}", e); + loop {} + } + }; + + // Destructure: `stack` is `Copy`, so we keep our own copy before moving + // `controller` and `runner` into their tasks. + let AsyncWifiHandle { + controller, + stack, + runner, + } = handle; + + spawner.must_spawn(wifi_task(controller)); + spawner.must_spawn(net_task(runner)); + + println!("Waiting for DHCPv4 lease..."); + stack.wait_config_up().await; + let v4 = stack + .config_v4() + .expect("stack reports config up but has no IPv4 config"); + println!( + "Wi-Fi connected — IP: {} gateway: {:?}", + v4.address, v4.gateway + ); + + // Idle loop — real applications would open sockets here. + loop { + Timer::after(Duration::from_secs(10)).await; + } +} + +// Initial association is started by `WiFiManager::init_async`; this task +// only handles reconnection after a disconnect event. +#[embassy_executor::task] +async fn wifi_task(mut controller: WifiController<'static>) { + loop { + controller.wait_for_event(WifiEvent::StaDisconnected).await; + println!("Wi-Fi disconnected — attempting to reconnect"); + Timer::after(Duration::from_millis(500)).await; + if let Err(e) = controller.connect() { + println!("reconnect failed: {:?}", e); + } + } +} + +#[embassy_executor::task] +async fn net_task(mut runner: embassy_net::Runner<'static, WifiDevice<'static>>) -> ! { + runner.run().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 new file mode 100644 index 0000000..9b57003 --- /dev/null +++ b/crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async_led.rs @@ -0,0 +1,152 @@ +//! Async Wi-Fi connect with onboard LED feedback for ESP32-C3 Super Mini. +//! +//! Extends `hal_c3_connect_async` with a spawned `led_task` that blinks +//! GPIO8 (the onboard active-low LED) while Wi-Fi is connecting, then +//! holds it steady once an IP address is acquired. +//! +//! The LED pattern is driven by a shared `AtomicBool` flag: +//! +//! * `false` (default) — LED blinks at ~2 Hz (connecting) +//! * `true` — LED stays on (connected) +//! +//! This pattern is reusable for any async application that needs status +//! feedback during a multi-step boot sequence. +//! +//! `WIFI_SSID` and `WIFI_PASS` must be set as environment variables **at build +//! time**. Requires the `embassy` Cargo feature. +//! +//! # Build and flash +//! +//! ```sh +//! WIFI_SSID="MyNetwork" WIFI_PASS="secret" just build-example hal_c3_connect_async_led +//! just flash hal_c3_connect_async_led +//! ``` + +#![no_std] +#![no_main] + +extern crate alloc; + +use core::sync::atomic::{AtomicBool, Ordering}; + +use embassy_executor::Spawner; +use embassy_time::{Duration, Timer}; +use esp_backtrace as _; +use esp_hal::gpio::{Level, Output, OutputConfig}; +use esp_println::println; +use esp_radio::wifi::{WifiController, WifiDevice, WifiEvent}; +use rustyfarian_esp_hal_wifi::{AsyncWifiHandle, WiFiConfig, WiFiConfigExt, WiFiManager}; + +esp_bootloader_esp_idf::esp_app_desc!(); + +const SSID: &str = match option_env!("WIFI_SSID") { + Some(s) => s, + None => "", +}; +const PASSWORD: &str = match option_env!("WIFI_PASS") { + Some(s) => s, + None => "", +}; + +/// Shared flag: `false` = connecting (blink), `true` = connected (steady). +static CONNECTED: AtomicBool = AtomicBool::new(false); + +#[esp_rtos::main] +async fn main(spawner: Spawner) { + esp_println::logger::init_logger(log::LevelFilter::Info); + + let peripherals = esp_hal::init(esp_hal::Config::default()); + + esp_alloc::heap_allocator!(size: 72 * 1024); + + // GPIO8 is the onboard LED on ESP32-C3 Super Mini (active-low). + // Start with LED off (pin high). + let led = Output::new(peripherals.GPIO8, Level::High, OutputConfig::default()); + + println!("Initializing Wi-Fi (async + LED)..."); + + let config = WiFiConfig::new(SSID, PASSWORD).with_peripherals( + peripherals.TIMG0, + peripherals.SW_INTERRUPT, + peripherals.WIFI, + ); + + let handle = match WiFiManager::init_async(config) { + Ok(h) => h, + Err(e) => { + println!("FATAL: Wi-Fi init failed: {}", e); + loop {} + } + }; + + let AsyncWifiHandle { + controller, + stack, + runner, + } = handle; + + spawner.must_spawn(wifi_task(controller)); + spawner.must_spawn(net_task(runner)); + spawner.must_spawn(led_task(led)); + + println!("Waiting for DHCPv4 lease (LED blinking)..."); + stack.wait_config_up().await; + let v4 = stack + .config_v4() + .expect("stack reports config up but has no IPv4 config"); + println!( + "Wi-Fi connected — IP: {} gateway: {:?}", + v4.address, v4.gateway + ); + + // Signal the LED task to hold steady. + CONNECTED.store(true, Ordering::Relaxed); + + loop { + Timer::after(Duration::from_secs(10)).await; + } +} + +/// Blinks the onboard LED while connecting; holds steady once connected. +/// +/// Uses active-low logic: `set_low()` = LED on, `set_high()` = LED off. +/// Blink rate is ~2 Hz (250 ms on, 250 ms off) for clear visibility. +/// +/// When `CONNECTED` transitions back to `false` (e.g. after a disconnect +/// detected by `wifi_task`), the LED resumes blinking automatically. +#[embassy_executor::task] +async fn led_task(mut led: Output<'static>) { + loop { + if CONNECTED.load(Ordering::Relaxed) { + // Connected: LED on (steady) + led.set_low(); + Timer::after(Duration::from_millis(100)).await; + } else { + // Connecting: blink at ~2 Hz + led.set_low(); + Timer::after(Duration::from_millis(250)).await; + led.set_high(); + Timer::after(Duration::from_millis(250)).await; + } + } +} + +// Initial association is started by `WiFiManager::init_async`; this task +// only handles reconnection after a disconnect event. +#[embassy_executor::task] +async fn wifi_task(mut controller: WifiController<'static>) { + loop { + controller.wait_for_event(WifiEvent::StaDisconnected).await; + println!("Wi-Fi disconnected — attempting to reconnect"); + CONNECTED.store(false, Ordering::Relaxed); + Timer::after(Duration::from_millis(500)).await; + if let Err(e) = controller.connect() { + println!("reconnect failed: {:?}", e); + } + } +} + +#[embassy_executor::task] +async fn net_task(mut runner: embassy_net::Runner<'static, WifiDevice<'static>>) -> ! { + runner.run().await +} diff --git a/crates/rustyfarian-esp-hal-wifi/examples/hal_c6_connect.rs b/crates/rustyfarian-esp-hal-wifi/examples/hal_c6_connect.rs index 69b6e53..1e4a468 100644 --- a/crates/rustyfarian-esp-hal-wifi/examples/hal_c6_connect.rs +++ b/crates/rustyfarian-esp-hal-wifi/examples/hal_c6_connect.rs @@ -24,6 +24,8 @@ #![no_std] #![no_main] +extern crate alloc; + use esp_backtrace as _; use esp_hal::main; use esp_println::println; @@ -43,10 +45,13 @@ const PASSWORD: &str = match option_env!("WIFI_PASS") { fn run() -> Result<(), WifiError> { let peripherals = esp_hal::init(esp_hal::Config::default()); + // ESP32-C6 requires two heap regions: reclaimed IRAM for Wi-Fi DMA + DRAM. + esp_alloc::heap_allocator!(#[esp_hal::ram(reclaimed)] size: 64 * 1024); + esp_alloc::heap_allocator!(size: 36 * 1024); + let config = WiFiConfig::new(SSID, PASSWORD).with_peripherals( peripherals.TIMG0, - peripherals.RNG, - peripherals.RADIO_CLK, + peripherals.SW_INTERRUPT, peripherals.WIFI, ); let mut wifi = WiFiManager::init(config)?; 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 new file mode 100644 index 0000000..4ff87b5 --- /dev/null +++ b/crates/rustyfarian-esp-hal-wifi/examples/hal_c6_connect_async_led.rs @@ -0,0 +1,173 @@ +//! Async Wi-Fi connect with WS2812 RGB LED feedback for ESP32-C6. +//! +//! Spawns a `led_task` that pulses the onboard WS2812 LED (GPIO8) blue +//! via [`PulseEffect`] while Wi-Fi is connecting, then holds a steady +//! dim green once an IP address is acquired. +//! +//! LED phases: +//! +//! * **Connecting** — blue pulse (~1.4 s cycle via `PulseEffect`) +//! * **Connected** — steady dim green (0, 20, 0) +//! * **Disconnected** — resumes blue pulse automatically +//! +//! `WIFI_SSID` and `WIFI_PASS` must be set as environment variables **at build +//! time**. Requires the `embassy` and `rustyfarian-esp-hal-ws2812` features. +//! +//! # Build and flash +//! +//! ```sh +//! WIFI_SSID="MyNetwork" WIFI_PASS="secret" just build-example hal_c6_connect_async_led +//! just flash hal_c6_connect_async_led +//! ``` + +#![no_std] +#![no_main] + +extern crate alloc; + +use core::sync::atomic::{AtomicBool, Ordering}; + +use embassy_executor::Spawner; +use embassy_time::{Duration, Timer}; +use esp_backtrace as _; +use esp_hal::gpio::Level; +use esp_hal::rmt::{Rmt, TxChannelConfig, TxChannelCreator}; +use esp_hal::time::Rate; +use esp_hal::Blocking; +use esp_println::println; +use esp_radio::wifi::{WifiController, WifiDevice, WifiEvent}; +use led_effects::{PulseEffect, StatusLed}; +use rgb::RGB8; +use rustyfarian_esp_hal_wifi::{AsyncWifiHandle, WiFiConfig, WiFiConfigExt, WiFiManager}; +use rustyfarian_esp_hal_ws2812::{buffer_size, Ws2812Rmt, RMT_CLK_DIV}; + +esp_bootloader_esp_idf::esp_app_desc!(); + +const SSID: &str = match option_env!("WIFI_SSID") { + Some(s) => s, + None => "", +}; +const PASSWORD: &str = match option_env!("WIFI_PASS") { + Some(s) => s, + None => "", +}; + +const NUM_LEDS: usize = 1; +const N: usize = buffer_size(NUM_LEDS); + +/// Shared flag: `false` = connecting (pulse), `true` = connected (steady green). +static CONNECTED: AtomicBool = AtomicBool::new(false); + +#[esp_rtos::main] +async fn main(spawner: Spawner) { + esp_println::logger::init_logger(log::LevelFilter::Info); + + let peripherals = esp_hal::init(esp_hal::Config::default()); + + // ESP32-C6 requires two heap regions: reclaimed IRAM for Wi-Fi DMA + // buffers, and regular DRAM for general allocations. + // (ESP32-C3 has contiguous SRAM and uses a single 72 KiB region instead.) + esp_alloc::heap_allocator!(#[esp_hal::ram(reclaimed)] size: 64 * 1024); + esp_alloc::heap_allocator!(size: 36 * 1024); + + // Set up the onboard WS2812 RGB LED on GPIO8 via RMT. + let rmt = Rmt::new(peripherals.RMT, Rate::from_mhz(80)).unwrap(); + let rmt_config = TxChannelConfig::default() + .with_clk_divider(RMT_CLK_DIV) + .with_idle_output_level(Level::Low) + .with_idle_output(true) + .with_carrier_modulation(false); + let channel = rmt + .channel0 + .configure_tx(peripherals.GPIO8, rmt_config) + .unwrap(); + let led = Ws2812Rmt::::new(channel); + println!("LED ready"); + + // WiFiManager::init_async() is synchronous (heap + RTOS + radio init). + // In embassy's cooperative model the LED task cannot run until init + // returns and main hits its first .await. Radio init is fast (~100 ms) + // once the heap is correctly configured with reclaimed IRAM. + println!("Initializing Wi-Fi..."); + + let config = WiFiConfig::new(SSID, PASSWORD).with_peripherals( + peripherals.TIMG0, + peripherals.SW_INTERRUPT, + peripherals.WIFI, + ); + + let handle = match WiFiManager::init_async(config) { + Ok(h) => h, + Err(e) => { + println!("FATAL: Wi-Fi init failed: {}", e); + loop {} + } + }; + println!("Wi-Fi radio ready"); + + let AsyncWifiHandle { + controller, + stack, + runner, + } = handle; + + spawner.must_spawn(wifi_task(controller)); + spawner.must_spawn(net_task(runner)); + spawner.must_spawn(led_task(led)); + + println!("Waiting for DHCPv4 lease (LED pulsing blue)..."); + stack.wait_config_up().await; + let v4 = stack + .config_v4() + .expect("stack reports config up but has no IPv4 config"); + println!( + "Wi-Fi connected — IP: {} gateway: {:?}", + v4.address, v4.gateway + ); + + CONNECTED.store(true, Ordering::Relaxed); + + loop { + Timer::after(Duration::from_secs(10)).await; + } +} + +/// Pulses the WS2812 LED blue while connecting; holds dim green once connected. +/// +/// Uses [`PulseEffect`] for smooth brightness animation at ~20 fps. +/// When `CONNECTED` transitions back to `false` (e.g. after disconnect), +/// the blue pulse resumes automatically. +#[embassy_executor::task] +async fn led_task(mut led: Ws2812Rmt<'static, Blocking, N>) { + let mut pulse = PulseEffect::new(); + + loop { + if CONNECTED.load(Ordering::Relaxed) { + let _ = led.set_color(RGB8::new(0, 20, 0)); + Timer::after(Duration::from_millis(100)).await; + } else { + let _ = led.set_color(pulse.update((0, 0, 255))); + Timer::after(Duration::from_millis(50)).await; + } + } +} + +// Initial association is started by `WiFiManager::init_async`; this task +// only handles reconnection after a disconnect event. +#[embassy_executor::task] +async fn wifi_task(mut controller: WifiController<'static>) { + loop { + controller.wait_for_event(WifiEvent::StaDisconnected).await; + println!("Wi-Fi disconnected — attempting to reconnect"); + CONNECTED.store(false, Ordering::Relaxed); + Timer::after(Duration::from_millis(500)).await; + if let Err(e) = controller.connect() { + println!("reconnect failed: {:?}", e); + } + } +} + +#[embassy_executor::task] +async fn net_task(mut runner: embassy_net::Runner<'static, WifiDevice<'static>>) -> ! { + runner.run().await +} diff --git a/crates/rustyfarian-esp-hal-wifi/examples/hal_c6_connect_nonblocking_rgb.rs b/crates/rustyfarian-esp-hal-wifi/examples/hal_c6_connect_nonblocking_rgb.rs index 2a5bd76..ea1f3cb 100644 --- a/crates/rustyfarian-esp-hal-wifi/examples/hal_c6_connect_nonblocking_rgb.rs +++ b/crates/rustyfarian-esp-hal-wifi/examples/hal_c6_connect_nonblocking_rgb.rs @@ -27,6 +27,8 @@ #![no_std] #![no_main] +extern crate alloc; + use esp_backtrace as _; use esp_hal::delay::Delay; use esp_hal::gpio::Level; @@ -64,11 +66,14 @@ fn run() -> Result<(), WifiError> { ); let delay = Delay::new(); + // ESP32-C6 requires two heap regions: reclaimed IRAM for Wi-Fi DMA + DRAM. + esp_alloc::heap_allocator!(#[esp_hal::ram(reclaimed)] size: 64 * 1024); + esp_alloc::heap_allocator!(size: 36 * 1024); + // Init Wi-Fi first — RMT can be set up after. let wifi_config = WiFiConfig::new(SSID, PASSWORD).with_peripherals( peripherals.TIMG0, - peripherals.RNG, - peripherals.RADIO_CLK, + peripherals.SW_INTERRUPT, peripherals.WIFI, ); let mut wifi = WiFiManager::init(wifi_config)?; @@ -90,12 +95,12 @@ fn run() -> Result<(), WifiError> { println!("LED ready"); // Phase 1: blue pulse while waiting for L2 association. - let start = esp_hal::time::now(); + let start = esp_hal::time::Instant::now(); loop { if wifi.is_connected()? { break; } - let elapsed_ms = (esp_hal::time::now() - start).to_millis(); + let elapsed_ms = (esp_hal::time::Instant::now() - start).as_millis(); if elapsed_ms >= TIMEOUT_MS { println!("Wi-Fi association timeout"); for _ in 0..20 { diff --git a/crates/rustyfarian-esp-hal-wifi/src/lib.rs b/crates/rustyfarian-esp-hal-wifi/src/lib.rs index 192bbb7..9f160c0 100644 --- a/crates/rustyfarian-esp-hal-wifi/src/lib.rs +++ b/crates/rustyfarian-esp-hal-wifi/src/lib.rs @@ -18,6 +18,15 @@ //! //! After association, call [`WiFiManager::take_sta_device`] to obtain the //! `WifiDevice` for use with `smoltcp` or `embassy-net`. +//! +//! # Async (embassy) support +//! +//! When the `embassy` Cargo feature is enabled, [`WiFiManager::init_async`] +//! returns an [`AsyncWifiHandle`] carrying the `WifiController`, an +//! `embassy_net::Stack`, and a `Runner` ready to be spawned in an +//! `#[embassy_executor::task]`. DHCPv4 is handled by `embassy-net` +//! automatically — call [`AsyncWifiHandle::wait_for_ip`] to await the first +//! lease. #![no_std] @@ -31,6 +40,51 @@ pub use wifi_pure::{ // Re-export StatusLed, SimpleLed, and NoLed from led_effects for convenience pub use led_effects::{NoLed, SimpleLed, StatusLed}; +// ─── ActiveLowLed ────────────────────────────────────────────────────────── + +/// Active-low GPIO LED adapter for the [`StatusLed`] trait. +/// +/// Identical to [`SimpleLed`] but inverts the polarity: the pin is driven +/// **low** to turn the LED on and **high** to turn it off. +/// +/// Many dev boards (e.g. ESP32-C3 Super Mini) wire their onboard LED +/// between VCC and a GPIO pin, so pulling the pin low completes the +/// circuit and lights the LED. +/// +/// Use with [`WiFiManager::init_with_led`] for connection status feedback +/// on boards with an active-low LED. +pub struct ActiveLowLed { + pin: P, + threshold: u8, +} + +impl ActiveLowLed

{ + /// Creates a new `ActiveLowLed` with the default brightness threshold (10). + pub fn new(pin: P) -> Self { + Self { + pin, + threshold: led_effects::DEFAULT_BRIGHTNESS_THRESHOLD, + } + } + + /// Creates a new `ActiveLowLed` with a custom brightness threshold. + pub fn with_threshold(pin: P, threshold: u8) -> Self { + Self { pin, threshold } + } +} + +impl StatusLed for ActiveLowLed

{ + type Error = P::Error; + + fn set_color(&mut self, color: rgb::RGB8) -> Result<(), Self::Error> { + if led_effects::exceeds_threshold(color, self.threshold) { + self.pin.set_low() // Active-low: low = LED on + } else { + self.pin.set_high() // Active-low: high = LED off + } + } +} + // ─── Real implementation (behind chip feature gates) ──────────────────────── #[cfg(any(feature = "esp32c6", feature = "esp32c3"))] @@ -53,13 +107,6 @@ mod driver { const CONNECTED: (u8, u8, u8) = (0, 20, 0); const ERROR: (u8, u8, u8) = (255, 0, 0); - /// Minimum heap size for the Wi-Fi radio stack (bytes). - const WIFI_HEAP_SIZE: usize = 72 * 1024; - - /// Heap backing store for the Wi-Fi radio stack. - static mut WIFI_HEAP: core::mem::MaybeUninit<[u8; WIFI_HEAP_SIZE]> = - core::mem::MaybeUninit::uninit(); - fn smoltcp_now() -> smoltcp::time::Instant { smoltcp::time::Instant::from_millis( esp_hal::time::Instant::now() @@ -166,9 +213,16 @@ mod driver { } impl WiFiManager<'_, NoLed> { - /// Initializes the heap, scheduler, radio, and Wi-Fi — then configures, + /// Initializes the scheduler, radio, and Wi-Fi — then configures, /// starts, and begins association in a single call. /// + /// # Heap requirement + /// + /// The caller must set up the heap via `esp_alloc::heap_allocator!` + /// **before** calling this method. On ESP32-C3 a single 72 KiB region + /// suffices; on ESP32-C6 two regions are needed (64 KiB reclaimed IRAM + /// for Wi-Fi DMA + 36 KiB DRAM). See the chip-specific examples. + /// /// Uses [`NoLed`] for status feedback (no visual output). /// For LED feedback during connection, use [`init_with_led`][WiFiManager::init_with_led]. pub fn init(config: HalWifiConfig<'_>) -> Result, WifiError> { @@ -196,15 +250,6 @@ mod driver { config: HalWifiConfig<'_>, led: S, ) -> Result, WifiError> { - // Set up the heap for Wi-Fi radio buffers. - unsafe { - esp_alloc::HEAP.add_region(esp_alloc::HeapRegion::new( - core::ptr::addr_of_mut!(WIFI_HEAP) as *mut u8, - WIFI_HEAP_SIZE, - esp_alloc::MemoryCapability::Internal.into(), - )); - } - let timg = TimerGroup::new(config.timg0); let sw_ints = SoftwareInterruptControl::new(config.sw_interrupt); esp_rtos::start(timg.timer0, sw_ints.software_interrupt0); @@ -367,6 +412,120 @@ mod driver { } } + // ─── Async (embassy) companion ────────────────────────────────────────── + + /// Handle returned by [`WiFiManager::init_async`] carrying the components + /// needed to drive Wi-Fi from async tasks. + /// + /// Users are expected to spawn two tasks: one that owns the + /// [`WifiController`] and runs its reconnection logic, and one that owns + /// the [`embassy_net::Runner`] and calls `runner.run().await`. + /// The [`embassy_net::Stack`] is `Copy` and can be shared with any number + /// of socket tasks. + #[cfg(feature = "embassy")] + pub struct AsyncWifiHandle { + /// Wi-Fi controller for `wifi_task` — owns association state. + pub controller: WifiController<'static>, + /// Network stack handle for opening sockets; `Copy`able. + pub stack: embassy_net::Stack<'static>, + /// Runner for `net_task` — `runner.run().await` must be polled + /// continuously in a dedicated task. + pub runner: embassy_net::Runner<'static, WifiDevice<'static>>, + } + + #[cfg(feature = "embassy")] + impl WiFiManager<'static, NoLed> { + /// Initializes the scheduler, radio, and Wi-Fi, starts association, + /// and hands off control to `embassy-net`. + /// + /// Returns an [`AsyncWifiHandle`] with the components required to + /// drive Wi-Fi from async tasks. The caller is responsible for + /// spawning a task that owns `handle.controller` (association loop) + /// and a task that calls `handle.runner.run().await` (network stack). + /// + /// The initial association is started before this function returns; + /// the `wifi_task` only needs to handle subsequent disconnects. + /// + /// DHCPv4 is configured automatically — await + /// [`AsyncWifiHandle::wait_for_ip`] to block until the first lease. + /// + /// # Heap requirement + /// + /// The caller must set up the heap via `esp_alloc::heap_allocator!` + /// **before** calling this method. On ESP32-C3 a single 72 KiB region + /// suffices; on ESP32-C6 two regions are needed (64 KiB reclaimed IRAM + /// for Wi-Fi DMA + 36 KiB DRAM). See the chip-specific async examples. + /// + /// # Socket budget + /// + /// The `embassy-net` stack is sized with `StackResources<3>`, which + /// covers DHCP plus one TCP and one UDP socket — the baseline used by + /// `embassy-net`'s own examples. Applications that need more + /// concurrent sockets must build their own stack from + /// [`take_sta_device`][WiFiManager::take_sta_device]. + /// + /// # One-shot + /// + /// This function must be called at most once per boot — it + /// initializes a `static` `StackResources` via [`StaticCell`] and + /// will panic on the second call. + pub fn init_async(config: HalWifiConfig<'_>) -> Result { + Self::init_inner(config, NoLed)?.into_async_handle() + } + + fn into_async_handle(mut self) -> Result { + use embassy_net::{Config, DhcpConfig, StackResources}; + use static_cell::StaticCell; + + let sta_device = self.sta_device.take().ok_or_else(|| { + log::error!("STA device already taken — cannot build embassy-net stack"); + WifiError::ConfigureFailed + })?; + + static RESOURCES: StaticCell> = StaticCell::new(); + let resources = RESOURCES.init(StackResources::<3>::new()); + + // Seed the stack's local-port RNG from the monotonic clock. + // This is not cryptographic — it is used only by `embassy-net` for + // ephemeral source-port randomization. Upgrade to the `esp-hal` RNG + // peripheral if `init_async` ever gains access to it. + let seed = esp_hal::time::Instant::now() + .duration_since_epoch() + .as_micros(); + + let (stack, runner) = embassy_net::new( + sta_device, + Config::dhcpv4(DhcpConfig::default()), + resources, + seed, + ); + + Ok(AsyncWifiHandle { + controller: self.controller, + stack, + runner, + }) + } + } + + #[cfg(feature = "embassy")] + impl AsyncWifiHandle { + /// Awaits until the `embassy-net` stack has an IPv4 configuration + /// (either via DHCP or static) and returns the full configuration: + /// CIDR address, default gateway, and DNS servers. + /// + /// Mirrors the blocking [`WiFiManager::wait_connected`] convenience. + /// For more control (custom timeout, concurrent LED animation), + /// poll [`embassy_net::Stack::config_v4`] directly alongside other + /// futures with `embassy_futures::select`. + pub async fn wait_for_ip(&self) -> embassy_net::StaticConfigV4 { + self.stack.wait_config_up().await; + self.stack + .config_v4() + .expect("stack reports config up but has no IPv4 config") + } + } + impl<'d, S: StatusLed> WifiDriver for WiFiManager<'d, S> where S::Error: core::fmt::Debug, @@ -433,6 +592,9 @@ mod driver { #[cfg(any(feature = "esp32c6", feature = "esp32c3"))] pub use driver::{HalWifiConfig, WiFiConfigExt, WiFiManager, WifiError}; +#[cfg(all(feature = "embassy", any(feature = "esp32c6", feature = "esp32c3")))] +pub use driver::AsyncWifiHandle; + // ─── Stub fallback (no chip feature — host compilation) ───────────────────── #[cfg(not(any(feature = "esp32c6", feature = "esp32c3")))] diff --git a/docs/embassy-integration-research.md b/docs/embassy-integration-research.md new file mode 100644 index 0000000..2a89046 --- /dev/null +++ b/docs/embassy-integration-research.md @@ -0,0 +1,282 @@ +# Embassy Integration Research + +Research into async Wi-Fi support for `rustyfarian-esp-hal-wifi` using the +embassy ecosystem. +Conducted 2026-03-20 against esp-hal 1.0, esp-radio 0.17, esp-rtos 0.2. + +## Current architecture (blocking) + +``` +WiFiManager::init() + ├─ esp_alloc::HEAP.add_region() (72 KiB) + ├─ esp_rtos::start(timer, sw_int) (starts RTOS scheduler) + ├─ esp_radio::init() (radio hardware) + ├─ esp_radio::wifi::new() (WiFi controller + device) + ├─ configure() / start() / connect() (STA mode) + └─ returns WiFiManager + +WiFiManager::wait_connected(timeout_ms) + ├─ loop { controller.is_connected()? } (blocking L2 poll) + ├─ smoltcp::Interface + dhcpv4::Socket (manual DHCP) + ├─ loop { iface.poll(); socket.poll() } (blocking DHCP poll) + └─ returns Ipv4Address +``` + +All code runs in the main thread. +`esp_rtos` provides a preemptive scheduler via timer interrupts so the WiFi +firmware blob's internal tasks can run. + +## What embassy brings + +Embassy replaces manual poll loops with cooperative async tasks: + +| Concern | Blocking (now) | Embassy (proposed) | +|:--------------|:---------------------------|:---------------------------------------------------------| +| WiFi connect | `loop { is_connected()? }` | `controller.connect_async().await` | +| DHCP | Manual smoltcp polling | `embassy_net::Config::dhcpv4()` — automatic | +| Socket I/O | Not implemented | `TcpSocket::new(stack, ...)` with async read/write | +| LED animation | Inline in poll loop | Separate async task, concurrent with WiFi | +| Runtime | `esp_rtos::start()` | `#[esp_rtos::main]` — same RTOS, embassy executor on top | + +## Options explored + +

+Option A: Embassy-native WiFiManager (full async) + +Replace `WiFiManager` with an async version that spawns embassy tasks. + +### How the example code would look + +```rust +#![no_std] +#![no_main] + +extern crate alloc; + +use esp_backtrace as _; +use embassy_executor::Spawner; +use embassy_net::{Config, Stack, StackResources}; +use embassy_time::{Duration, Timer}; +use esp_println::println; +use rustyfarian_esp_hal_wifi::{WiFiConfig, WiFiConfigExt, WiFiManager}; + +esp_bootloader_esp_idf::esp_app_desc!(); + +const SSID: &str = env!("WIFI_SSID"); +const PASSWORD: &str = env!("WIFI_PASS"); + +#[esp_rtos::main] +async fn main(spawner: Spawner) -> ! { + let peripherals = esp_hal::init(esp_hal::Config::default()); + + // Two calls, one global esp_alloc::HEAP with two backing regions. + // Region 1 — reclaimed IRAM (64 KiB, DMA-accessible): ROM/bootloader + // memory freed after esp_hal::init(). WiFi RX/TX ring buffers require + // DMA-accessible SRAM, so this region must exist before esp_radio::init(). + // Region 2 — regular DRAM (36 KiB): general-purpose heap (Box, Vec, etc.). + // ESP32-C6's SRAM banks are physically separate; ESP32-C3 has one contiguous + // block and uses a single call (72 KiB) instead. + esp_alloc::heap_allocator!(#[esp_hal::ram(reclaimed)] size: 64 * 1024); + esp_alloc::heap_allocator!(size: 36 * 1024); + + let wifi = WiFiManager::init_async( + WiFiConfig::new(SSID, PASSWORD) + .with_peripherals(peripherals.TIMG0, peripherals.SW_INTERRUPT, peripherals.WIFI), + ).unwrap(); + + spawner.spawn(wifi_task(wifi.controller)).ok(); + spawner.spawn(net_task(wifi.runner)).ok(); + + loop { + if let Some(config) = wifi.stack.config_v4() { + println!("IP: {}", config.address.address()); + break; + } + Timer::after(Duration::from_millis(500)).await; + } + + loop { + Timer::after(Duration::from_secs(10)).await; + } +} + +#[embassy_executor::task] +async fn wifi_task(mut controller: WifiController<'static>) { + loop { + controller.connect_async().await.ok(); + controller.wait_for_event(WifiEvent::StaDisconnected).await; + } +} + +#[embassy_executor::task] +async fn net_task(runner: Runner<'static, WifiDevice<'static>>) { + runner.run().await; +} +``` + +### What WiFiManager would provide + +```rust +pub struct AsyncWifiHandle { + pub controller: WifiController<'static>, + pub stack: &'static Stack>, + pub runner: Runner<'static, WifiDevice<'static, WifiStaDevice>>, +} + +impl WiFiManager { + pub fn init_async(config: HalWifiConfig<'_>) -> Result { + // Same heap/rtos/radio init as today, returns components to spawn + } +} +``` + +### Trade-offs + +- **Pro**: Proper concurrency — LED, WiFi reconnection, and DHCP as independent tasks +- **Pro**: No manual smoltcp polling — `embassy-net` handles it +- **Pro**: Clean async/await code, no `delay.delay_millis()` busy-waits +- **Con**: Requires `#[esp_rtos::main]` entry point (async main) +- **Con**: Users must understand embassy task spawning +- **Con**: `'static` lifetime requirements for tasks are infectious — needs `mk_static!` or `Box::leak` + +
+ +
+Option B: Keep blocking WiFiManager, add async companion + +Keep the existing blocking `WiFiManager` for simple use cases. +Add a separate `WiFiManagerAsync` for embassy users. + +```rust +// Simple blocking usage (unchanged) +let mut wifi = WiFiManager::init(config)?; +let ip = wifi.wait_connected(30_000)?; + +// Async usage (new) +let handle = WiFiManagerAsync::init(config)?; +spawner.spawn(handle.connection_task()).ok(); +spawner.spawn(handle.net_task()).ok(); +let ip = handle.wait_for_ip().await; +``` + +### Trade-offs + +- **Pro**: No breaking change to existing API +- **Pro**: Users choose blocking or async based on their needs +- **Con**: Two code paths to maintain +- **Con**: `init_inner` would need to branch or be duplicated + +
+ +
+Option C: Async-first with blocking wrapper + +Make the core implementation async, then provide a blocking wrapper +that runs the async code on a single-shot executor. + +```rust +impl WiFiManager { + async fn connect_async(&mut self) -> Result<(), WifiError> { + self.controller.connect_async().await.map_err(WifiError::Driver) + } + + pub fn wait_connected(&mut self, timeout_ms: u64) -> Result { + embassy_futures::block_on(self.wait_connected_async(timeout_ms)) + } +} +``` + +### Trade-offs + +- **Pro**: Single implementation, two interfaces +- **Pro**: Async-first is the ecosystem direction +- **Con**: `block_on` in embedded is tricky — needs the executor to be running +- **Con**: `embassy_futures::block_on` only works for simple futures, not multi-task scenarios + +
+ +
+Option D: Provide building blocks, not a manager + +Expose initialized components and let users compose them. + +```rust +let components = wifi_init(config)?; + +// User A: blocking with smoltcp +let mut mgr = BlockingWifiManager::from(components); +let ip = mgr.wait_connected(30_000)?; + +// User B: embassy-net +let (stack, runner) = embassy_net::new(components.sta_device, ...); +spawner.spawn(net_task(runner)).ok(); +``` + +### Trade-offs + +- **Pro**: Maximum flexibility +- **Pro**: No opinion on async vs blocking baked into the library +- **Con**: More boilerplate for users +- **Con**: Less "batteries included" + +
+ +## LED animation with embassy + +The biggest win for embassy is concurrent LED animation. +Currently, the LED pulse is interleaved with WiFi polling in the same loop. +With embassy, it becomes a separate task: + +```rust +#[embassy_executor::task] +async fn led_task(mut led: impl StatusLed, state: &'static WifiState) { + let mut pulse = PulseEffect::new(); + loop { + let color = match state.get() { + State::Connecting => pulse.update(WIFI_CONNECTING), + State::Connected => RGB8::from(CONNECTED), + State::Error => pulse.update(ERROR), + }; + let _ = led.set_color(color); + Timer::after(Duration::from_millis(50)).await; + } +} +``` + +This decouples LED animation from WiFi polling — smoother animation, +cleaner code, and the LED keeps pulsing even during long DHCP negotiation. + +## Recommendation + +**Option B (blocking + async companion)** is the pragmatic choice: + +1. The blocking API works and is validated on hardware (ESP32-C3) +2. Embassy support can be added incrementally without breaking existing users +3. The `take_sta_device()` method already enables embassy-net integration by downstream code +4. A full async rewrite (Option A/C) should wait until `esp-radio` is stable on all chips (C6 bug pending) + +### Incremental path + +1. **Now**: Keep blocking `WiFiManager` as-is (working on C3) +2. **Next**: Add `embassy` feature flag to `rustyfarian-esp-hal-wifi` +3. **Next**: Add `WiFiManagerAsync` behind the feature flag +4. **Next**: Add embassy examples (`hal_c3_connect_async`) +5. **Later**: If embassy becomes the primary use case, consider Option C (async-first) + +## New dependencies required (all optional, behind `embassy` feature) + +| Crate | Version | Purpose | +|:--------------------|:--------|:-----------------------------------------| +| `embassy-executor` | 0.9 | Task spawner | +| `embassy-net` | 0.7 | Async network stack (wraps smoltcp) | +| `embassy-time` | 0.5 | Async timers (`Timer::after`) | +| `static_cell` | 2.1 | Safe `'static` allocation for task state | +| `embedded-io-async` | 0.6 | Async I/O traits for sockets | + +`esp-rtos` already supports embassy via its `embassy` feature. + +## Open questions + +- Should the `WifiDriver` trait in `wifi-pure` get async counterparts (`connect_async`, etc.)? +- Should LED animation always use embassy tasks, or keep the inline approach for blocking mode? +- Is `esp-rtos` with `embassy` feature stable enough for production on ESP32-C3? diff --git a/docs/features/embassy-feature-flag-v1.md b/docs/features/embassy-feature-flag-v1.md new file mode 100644 index 0000000..9617ee2 --- /dev/null +++ b/docs/features/embassy-feature-flag-v1.md @@ -0,0 +1,66 @@ +# Feature: Embassy Feature Flag v1 + +Foundation work to prepare `rustyfarian-esp-hal-wifi` for async integration. +Adds an opt-in `embassy` Cargo feature that pulls in the embassy ecosystem crates without changing any existing behavior. +This is a prerequisite for `wifi-manager-async-v1` and `hal-c3-connect-async-example-v1`. + +Source: `docs/embassy-integration-research.md` (2026-03-20), Option B "blocking + async companion" recommendation. + +> **Scope note:** This document covers only the dependency / feature wiring. +> The async API (`WiFiManager::init_async`, `AsyncWifiHandle`, `wait_for_ip`) +> ships in the same PR but is owned by `wifi-manager-async-v1.md`. +> Async examples are owned by `hal-c3-connect-async-example-v1.md`. + +## Decisions + +| Decision | Reason | Rejected Alternative | +|:-----------------------------------------------------------------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------|:-----------------------------------------------------------------------------------------| +| Introduce `embassy` feature flag, off by default | Keeps blocking users unaffected; no new compile time or binary size cost unless opted in | Embassy as a mandatory dependency — forces async on users with working blocking code | +| Feature flag lives on `rustyfarian-esp-hal-wifi` only (not `wifi-pure`) | `wifi-pure` is platform-independent pure logic; async runtime belongs in the HAL crate | Feature on `wifi-pure` — leaks runtime choice into shared types, breaks host tests | +| All embassy crates declared `optional = true` | Standard Cargo pattern for feature-gated deps; enables clean `cfg(feature = "embassy")` blocks | `dev-dependencies` only — wouldn't expose types in the public API | +| Pin exact versions per research doc: `embassy-executor 0.9`, `embassy-net 0.7`, `embassy-time 0.5`, `static_cell 2.1`, `embedded-io-async 0.6` | Matches versions compatible with `esp-rtos 0.2` / `esp-radio 0.17` already in use | Floating latest — risk of breaking changes during routine `cargo update` | +| `embassy` feature activates `esp-rtos/embassy` transitively | `esp-rtos` already supports embassy via this feature; avoids users having to know the integration detail | Requiring users to enable `esp-rtos/embassy` manually — undocumented, error-prone | +| No `WiFiManagerAsync` added in this feature | Scope isolation — this feature is pure foundation, validated by `cargo check --features embassy` only | Bundling API work — makes the change larger and harder to review | +| Add `just check-embassy` recipe to verify feature-on build | CI and local contributors need an easy way to verify the feature gate compiles; `just verify` uses default features | Relying on contributors to remember `cargo check --features embassy` — will be forgotten | + +## Constraints + +- No behavior change for existing blocking users — `just verify` must remain green with default features +- No new runtime dependencies when `embassy` feature is off — verified via `cargo tree -e normal --no-default-features` +- `deny.toml` must pass for all new crates (licenses, advisories, duplicates) +- No API surface added by **this feature** — purely a dependency/feature wiring change. The async API surface added by `wifi-manager-async-v1` is gated on the same `embassy` feature. +- Must work on ESP32-C3 (riscv32imc) and ESP32-C6 (riscv32imac) bare-metal targets +- Does not touch `rustyfarian-esp-idf-wifi` — embassy work is HAL-only + +## New dependencies (all `optional = true`) + +| Crate | Version | Purpose | +|:--------------------|:--------|:-----------------------------------------------| +| `embassy-executor` | 0.9 | Async task spawner | +| `embassy-net` | 0.7 | Async network stack (wraps smoltcp) | +| `embassy-time` | 0.5 | Async timers (`Timer::after`) | +| `static_cell` | 2.1 | Safe `'static` allocation for task state | +| `embedded-io-async` | 0.6 | Async I/O traits for sockets | + +`esp-rtos/embassy` feature is enabled transitively; no new crate required for that. + +## Open Questions + +- [x] Do any of the new crates introduce duplicate versions of `heapless`, `smoltcp`, or `embedded-hal` already pinned in the workspace? — `cargo deny` bans check passed clean; `embassy-net 0.7.1` resolves to the same `smoltcp 0.12.0` as the workspace; minor `embedded-io` 0.6/0.7 and `rand_core` 0.6/0.9 duplicates exist but are within the `multiple-versions = "warn"` threshold and did not trip `deny` +- [x] Does `embassy-net 0.7` pull in a `smoltcp` version compatible with the 0.12.0 entry already allowed in `deny.toml`? — Yes, exact match with workspace pin +- [x] Should the feature be named `embassy` or `async`? — Named `embassy`; it is honest about the runtime choice and leaves room for a runtime-agnostic `async` feature later if needed + +## State + +- [x] Design approved +- [x] Core implementation (Cargo.toml edits + feature block) +- [x] `cargo check --features embassy` passes for ESP32-C3 and ESP32-C6 (via `just check-wifi-hal-embassy`, both bare-metal targets with `-Zbuild-std=core,alloc`) +- [x] `cargo deny check` passes with the new deps +- [x] `just verify` passes (default features unchanged) +- [x] `just check-embassy` recipe added (named `check-wifi-hal-embassy` to match the existing `check-wifi-hal` convention) +- [x] CHANGELOG entry + +## Session Log + +- 2026-04-08 — Feature doc created from `docs/embassy-integration-research.md` +- 2026-04-08 — Implemented: workspace deps added, `embassy` feature block added to `rustyfarian-esp-hal-wifi`, `check-wifi-hal-embassy` just recipe added (uses `-Zbuild-std=core,alloc` for RISC-V bare-metal targets), CHANGELOG updated. `just fmt`, `just verify`, and the new recipe all pass clean on ESP32-C6 and ESP32-C3. diff --git a/docs/features/hal-c3-connect-async-example-v1.md b/docs/features/hal-c3-connect-async-example-v1.md new file mode 100644 index 0000000..3f9a6f5 --- /dev/null +++ b/docs/features/hal-c3-connect-async-example-v1.md @@ -0,0 +1,65 @@ +# Feature: hal_c3_connect_async Example v1 + +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. + +## Decisions + +| 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 | + +## Constraints + +- 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 verify` must remain green — the example does not enter the default verification build because it requires the `embassy` feature +- `just check-embassy` (added in feature 1) should cover the example in `cargo check` form if feasible + +## Validation checklist (on hardware) + +- [x] `just build-example hal_c3_connect_async` succeeds +- [x] `just flash hal_c3_connect_async` flashes cleanly +- [x] Serial output shows "Wi-Fi connected" and a valid IP address from DHCP +- [x] 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) + +## Open Questions + +- [x] 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 +- [x] Should the example include a visible LED indicator? — Out of scope; see `led-task-embassy-v1` (future feature) +- [x] Do we need a `build.rs` / `sdkconfig.defaults` for bare-metal? — No; pure `esp-hal` + `esp-radio` + `esp-rtos`, no ESP-IDF involvement +- [x] Bootloader situation on C3 bare-metal? — Use espflash's bundled bootloader, same as the existing `hal_c3_connect` blocking example (no custom routing needed) + +## State + +- [x] Design approved +- [x] `embassy-feature-flag-v1` landed (blocker) +- [x] `wifi-manager-async-v1` landed (blocker) +- [x] Example file created (`crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async.rs`) +- [x] `just build-example hal_c3_connect_async` succeeds (release profile, `riscv32imc-unknown-none-elf`, all deps compile clean) +- [ ] Hardware validation checklist complete — **connect + DHCP verified; AP reconnect loop still open** +- [x] CHANGELOG entry + +## Session Log + +- 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. diff --git a/docs/features/wifi-manager-async-v1.md b/docs/features/wifi-manager-async-v1.md new file mode 100644 index 0000000..bc56862 --- /dev/null +++ b/docs/features/wifi-manager-async-v1.md @@ -0,0 +1,75 @@ +# Feature: WiFiManager Async Companion v1 + +Add an async API to `rustyfarian-esp-hal-wifi` that lives alongside the existing blocking `WiFiManager`. +Replaces the manual smoltcp DHCP polling loop with `embassy-net` and exposes components suitable for embassy task spawning. + +Depends on `embassy-feature-flag-v1`. +Consumed by `hal-c3-connect-async-example-v1`. + +Source: `docs/embassy-integration-research.md` — Option B "blocking + async companion". + +## Decisions + +| Decision | Reason | Rejected Alternative | +|:----------------------------------------------------------------------------------------------|:------------------------------------------------------------------------------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------| +| New `WiFiManagerAsync` type alongside `WiFiManager`, gated on `embassy` feature | Option B from research — incremental, non-breaking, lets blocking users continue unchanged | Option A (full replacement) — breaks working C3 blocking path; Option C (async-first + block_on) — block_on is fragile in embedded multi-task scenarios | +| `init_async()` returns an `AsyncWifiHandle { controller, stack, runner }` | Matches embassy-net canonical pattern; users spawn `wifi_task(controller)` and `net_task(runner)` themselves | Hiding spawning inside the library — would require the library to own the `Spawner`, infectious `'static` requirements | +| Refactor shared init into a private `init_inner()` used by both blocking and async paths | Avoids duplicating heap region setup, `esp_rtos::start`, `esp_radio::init`, and `wifi::new` across two code paths | Copy-paste — two places to fix bugs, guaranteed drift | +| `embassy-net` replaces the manual `smoltcp::Interface` + `dhcpv4::Socket` polling | Eliminates the blocking poll loop; DHCP becomes automatic via `Config::dhcpv4()` | Keeping the manual smoltcp path — duplicates logic embassy-net already provides correctly | +| `wait_for_ip().await` convenience on the handle for the common "wait until online" case | Every async user needs this; makes the simple case a one-liner mirroring blocking `wait_connected()` | Forcing users to poll `stack.config_v4()` themselves — boilerplate in every example | +| `StackResources` sized for N=3 sockets by default; exposed via builder if needed | DHCP + one TCP + one UDP is the baseline; matches embassy-net examples | Hard-coding N=1 — can't run DHCP and user sockets concurrently | +| Feature requires `alloc` (already present); `Stack` is stored via `StaticCell` / `mk_static!` | `embassy-net` `Stack` needs `'static` lifetime; `StaticCell` is the idiomatic no_std pattern | `Box::leak` — works but `static_cell` is safer and clearer | +| `WifiDriver` trait in `wifi-pure` stays synchronous for this feature | Async trait extensions are an open design question (see feature 5); don't block this work on that decision | Adding `connect_async` to the trait now — premature, ties `wifi-pure` to a specific async runtime | +| Blocking `WiFiManager::wait_connected()` remains unchanged and deprecated-free | Research explicitly recommends keeping the working blocking path; it is validated on hardware | Removing or deprecating the blocking API — breaks `hal_c3_connect` example and existing users | + +## Constraints + +- No changes to the blocking `WiFiManager` public API — existing users must compile and run unchanged +- `just verify` with default features must continue to pass (blocking-only build) +- Async path compiles and type-checks for ESP32-C3 and ESP32-C6 targets +- Hardware validation is deferred to `hal-c3-connect-async-example-v1` (this feature lands behind the feature flag without a working example in the same PR is acceptable as long as types check) +- `AsyncWifiHandle` components must be `'static` (required by embassy tasks) — enforced by the API shape +- Must not require users to enable `esp-rtos/embassy` manually — handled by `embassy` feature activation from `embassy-feature-flag-v1` + +## API sketch + +```rust +#[cfg(feature = "embassy")] +pub struct AsyncWifiHandle { + pub controller: WifiController<'static>, + pub stack: embassy_net::Stack<'static>, + pub runner: embassy_net::Runner<'static, WifiDevice<'static>>, +} + +#[cfg(feature = "embassy")] +impl WiFiManager { + pub fn init_async(config: HalWifiConfig<'_>) -> Result; +} + +#[cfg(feature = "embassy")] +impl AsyncWifiHandle { + pub async fn wait_for_ip(&self) -> embassy_net::StaticConfigV4; +} +``` + +## Open Questions + +- [x] Should `init_async()` be a method on `WiFiManager` or a free function / separate builder? — Kept as `WiFiManager::init_async` on `WiFiManager<'static, NoLed>`. Discoverable alongside the blocking `init`; the different return type (`AsyncWifiHandle`) is self-documenting +- [x] Where should the `StaticCell` live — inside the library or pushed to the caller via a macro? — Inside `into_async_handle()` as a function-local `static`. Simplest API, caller needs zero boilerplate. `init_async` is implicitly one-shot per boot, which matches hardware reality; a second call panics at `RESOURCES.init()` +- [x] Does `embassy-net 0.7` still expose `Stack` as a generic over the device? — No: `Stack<'d>` is lifetime-only in 0.7.1 (the device is erased behind `DriverAdapter`). `Runner<'d, D: Driver>` still carries the driver type. Updated struct shape to `Stack<'static>` + `Runner<'static, WifiDevice<'static>>` +- [x] Should `wait_for_ip().await` have a timeout variant? — No. Users who need a timeout can wrap the call in `embassy_time::with_timeout(..)`; a second variant would add API surface with no new capability + +## State + +- [x] Design approved +- [x] `embassy-feature-flag-v1` landed (blocker) +- [x] `init_inner()` refactor — blocking path still works (no refactor was actually needed; the existing `init_inner` already produces everything `init_async` consumes via a new `into_async_handle()` method) +- [x] `init_async()` + `AsyncWifiHandle` implemented +- [x] `wait_for_ip()` helper implemented +- [x] Type-check passes for C3 and C6 (`just check-wifi-hal-embassy` — both bare-metal targets clean) +- [x] CHANGELOG entry + +## Session Log + +- 2026-04-08 — Feature doc created from `docs/embassy-integration-research.md` +- 2026-04-08 — Implemented: `AsyncWifiHandle` struct added to the driver module, `WiFiManager::init_async` + private `into_async_handle()` method, `AsyncWifiHandle::wait_for_ip()` helper. `WifiDevice` already implements `embassy_net_driver::Driver` unconditionally via `esp-radio/wifi`, so no feature bridging was needed. `StackResources<3>` baseline (DHCP + 1 TCP + 1 UDP) wired via a function-local `StaticCell`. Seeded embassy-net's RNG from `esp_hal::time::Instant::now().duration_since_epoch().as_micros()`. `just fmt`, `just verify`, and `just check-wifi-hal-embassy` all pass clean on ESP32-C6 and ESP32-C3. diff --git a/justfile b/justfile index 6b3ecd5..fd39ba1 100644 --- a/justfile +++ b/justfile @@ -53,6 +53,12 @@ check-lora-hal: check-wifi-hal: cargo check -p rustyfarian-esp-hal-wifi --no-default-features +# check the esp-hal wifi crate with the opt-in `embassy` feature (ESP32-C6 + ESP32-C3) +# `-Zbuild-std=core,alloc` overrides the workspace [unstable] build-std default. +check-wifi-hal-embassy: + cargo check -Zbuild-std=core,alloc --target riscv32imac-unknown-none-elf -p rustyfarian-esp-hal-wifi --no-default-features --features esp32c6,rt,embassy + cargo check -Zbuild-std=core,alloc --target riscv32imc-unknown-none-elf -p rustyfarian-esp-hal-wifi --no-default-features --features esp32c3,rt,embassy + # run clippy on the entire workspace clippy: cargo clippy --all-targets --workspace -- -D warnings diff --git a/scripts/build-example.sh b/scripts/build-example.sh index 7fa5129..5122b7e 100755 --- a/scripts/build-example.sh +++ b/scripts/build-example.sh @@ -76,7 +76,10 @@ case "$prefix" in # Append optional features based on example name case "$example" in - *_rgb*) hal_features="${hal_features},rustyfarian-esp-hal-ws2812" ;; + *_rgb*|hal_c6_*_led*) hal_features="${hal_features},rustyfarian-esp-hal-ws2812" ;; + esac + case "$example" in + *_async*) hal_features="${hal_features},embassy" ;; esac printf 'Building %s for bare-metal %s (MCU=%s)...\n' "$example" "$target" "$mcu" diff --git a/scripts/flash.sh b/scripts/flash.sh index ca17e2b..d785091 100755 --- a/scripts/flash.sh +++ b/scripts/flash.sh @@ -108,7 +108,10 @@ case "$prefix" in # Append optional features based on example name case "$example" in - *_rgb*) hal_features="${hal_features},rustyfarian-esp-hal-ws2812" ;; + *_rgb*|hal_c6_*_led*) hal_features="${hal_features},rustyfarian-esp-hal-ws2812" ;; + esac + case "$example" in + *_async*) hal_features="${hal_features},embassy" ;; esac # Ensure IDF-built bootloader is cached