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