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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<P>` 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`)
Expand Down
7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }

Expand Down
33 changes: 33 additions & 0 deletions crates/rustyfarian-esp-hal-wifi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
Expand All @@ -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"]
3 changes: 3 additions & 0 deletions crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
117 changes: 117 additions & 0 deletions crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async.rs
Original file line number Diff line number Diff line change
@@ -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
}
152 changes: 152 additions & 0 deletions crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async_led.rs
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading