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
12 changes: 10 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

## Project Overview

`rustyfarian-network` is a Rust workspace providing Wi-Fi, MQTT, LoRa, and ESP-NOW networking libraries for ESP32 firmware.
`rustyfarian-network` is a Rust workspace providing Wi-Fi, MQTT, LoRa, ESP-NOW, and OTA networking libraries for ESP32 firmware.
Two implementation tiers coexist: an ESP-IDF tier (`rustyfarian-esp-idf-*`, std-based) and a bare-metal `esp-hal` tier (`rustyfarian-esp-hal-*`, no_std).
Both tiers share platform-independent `*-pure` crates that compile and unit-test on any host without the ESP toolchain.

Expand All @@ -19,6 +19,7 @@ ADRs in `docs/adr/` document each architectural split.
| `wifi-pure` | `rustyfarian-esp-idf-wifi` | `rustyfarian-esp-hal-wifi` |
| `lora-pure` | `rustyfarian-esp-idf-lora` | `rustyfarian-esp-hal-lora` |
| `espnow-pure` | `rustyfarian-esp-idf-espnow` | (not yet) |
| `ota-pure` | `rustyfarian-esp-idf-ota` | `rustyfarian-esp-hal-ota` |
| `rustyfarian-network-pure` | `rustyfarian-esp-idf-mqtt` | (planned) |

Pure crates contain validation, types, traits, state machines, and timing math.
Expand All @@ -41,7 +42,7 @@ just run <name> # flash + serial monitor

Run `just fmt` before `just verify` — the latter's `fmt-check` will reject unformatted code.
`just verify` only compiles the workspace default target (`riscv32imac-esp-espidf`); use `just build-example <name>` to validate Xtensa IDF and bare-metal targets.
Pure crates iterate fast without the ESP toolchain — see `just check-wifi-pure`, `just test-wifi`, etc.
Pure crates iterate fast without the ESP toolchain — see `just check-wifi-pure`, `just test-wifi`, `just test-ota`, etc.

## Key Conventions

Expand All @@ -54,6 +55,13 @@ Pure crates iterate fast without the ESP toolchain — see `just check-wifi-pure
- **Cross-repo git deps** must be pinned with `tag` or `rev` — the workspace pulls in `links = "..."` crates that fail to resolve if upstream bumps without coordination.
- **License:** dual MIT / Apache-2.0 (see `LICENSE`).

## Coding Principles

- **State assumptions** before starting. If a task has multiple valid interpretations, present them rather than picking silently.
- **Simplicity first.** Minimum code that solves the problem. No features beyond what was asked. No abstractions for single-use code. No error handling for impossible scenarios.
- **Surgical changes.** Touch only what the task requires. Do not improve adjacent code, comments, or formatting. Every changed line should trace directly to the user's request.
- When your changes create orphans (unused imports, variables, functions), remove them. Do not remove pre-existing dead code unless asked.

## Important Files

| File | Why read it |
Expand Down
40 changes: 28 additions & 12 deletions crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use esp_backtrace as _;
use esp_println::println;
use esp_radio::wifi::{Interface, WifiController};
use esp_radio::wifi::{scan::ScanConfig, Interface, WifiController};
use rustyfarian_esp_hal_wifi::{AsyncWifiHandle, WiFiConfig, WiFiConfigExt, WiFiManager};

esp_bootloader_esp_idf::esp_app_desc!();
Expand Down Expand Up @@ -73,11 +73,24 @@ async fn main(spawner: Spawner) {
// Destructure: `stack` is `Copy`, so we keep our own copy before moving
// `controller` and `runner` into their tasks.
let AsyncWifiHandle {
controller,
mut controller,
stack,
runner,
} = handle;

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

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

Expand All @@ -97,20 +110,23 @@ async fn main(spawner: Spawner) {
}
}

// Initial association is started by `WiFiManager::init_async`; this task
// only handles reconnection after a disconnect event.
// Handles both the initial association and any subsequent reconnects.
// `set_config` starts the radio but does NOT initiate association in
// esp-radio 0.18 — `connect_async` must always be called explicitly.
#[embassy_executor::task]
async fn wifi_task(mut controller: WifiController<'static>) {
// `wait_for_disconnect_async` + `connect_async` replace the sync
// `wait_for_event(StaDisconnected)` + `connect` pair removed in
// esp-radio 0.18.
loop {
let _ = controller.wait_for_disconnect_async().await;
println!("Wi-Fi disconnected — attempting to reconnect");
Timer::after(Duration::from_millis(500)).await;
if let Err(e) = controller.connect_async().await {
println!("reconnect failed: {:?}", e);
match controller.connect_async().await {
Ok(_) => {
// Connected — block until the link drops.
let _ = controller.wait_for_disconnect_async().await;
println!("Wi-Fi disconnected — reconnecting...");
}
Err(e) => {
println!("connect failed: {:?}", e);
}
}
Timer::after(Duration::from_millis(500)).await;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,22 +148,23 @@ async fn led_task(mut led: Output<'static>) {
}
}

// Initial association is started by `WiFiManager::init_async`; this task
// only handles reconnection after a disconnect event.
// Handles both the initial association and subsequent reconnects.
// The LED `CONNECTED` flag is owned by `link_status_task` watching
// `embassy_net::Stack` config-up/config-down edges — not toggled here,
// because a successful L2 reconnect does not yet imply a new DHCP lease.
#[embassy_executor::task]
async fn wifi_task(mut controller: WifiController<'static>) {
// `wait_for_disconnect_async` and `connect_async` replace the
// sync `wait_for_event` + `connect` pair removed in esp-radio 0.18.
// The LED `CONNECTED` flag is owned by `link_status_task` watching
// `embassy_net::Stack` config-up/config-down edges — not toggled here,
// because a successful L2 reconnect does not yet imply a new DHCP lease.
loop {
let _ = controller.wait_for_disconnect_async().await;
println!("Wi-Fi disconnected — attempting to reconnect");
Timer::after(Duration::from_millis(500)).await;
if let Err(e) = controller.connect_async().await {
println!("reconnect failed: {:?}", e);
match controller.connect_async().await {
Ok(_) => {
let _ = controller.wait_for_disconnect_async().await;
println!("Wi-Fi disconnected — reconnecting...");
}
Err(e) => {
println!("connect failed: {:?}", e);
}
}
Timer::after(Duration::from_millis(500)).await;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,22 +173,23 @@ async fn led_task(mut led: Ws2812Rmt<'static, Blocking, N>) {
}
}

// Initial association is started by `WiFiManager::init_async`; this task
// only handles reconnection after a disconnect event.
// Handles both the initial association and subsequent reconnects.
// The LED `CONNECTED` flag is owned by `link_status_task` watching
// `embassy_net::Stack` config-up/config-down edges — not toggled here,
// because a successful L2 reconnect does not yet imply a new DHCP lease.
#[embassy_executor::task]
async fn wifi_task(mut controller: WifiController<'static>) {
// `wait_for_disconnect_async` and `connect_async` replace the
// sync `wait_for_event` + `connect` pair removed in esp-radio 0.18.
// The LED `CONNECTED` flag is owned by `link_status_task` watching
// `embassy_net::Stack` config-up/config-down edges — not toggled here,
// because a successful L2 reconnect does not yet imply a new DHCP lease.
loop {
let _ = controller.wait_for_disconnect_async().await;
println!("Wi-Fi disconnected — attempting to reconnect");
Timer::after(Duration::from_millis(500)).await;
if let Err(e) = controller.connect_async().await {
println!("reconnect failed: {:?}", e);
match controller.connect_async().await {
Ok(_) => {
let _ = controller.wait_for_disconnect_async().await;
println!("Wi-Fi disconnected — reconnecting...");
}
Err(e) => {
println!("connect failed: {:?}", e);
}
}
Timer::after(Duration::from_millis(500)).await;
}
}

Expand Down
68 changes: 40 additions & 28 deletions crates/rustyfarian-esp-hal-wifi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,21 +203,21 @@ mod driver {
pub struct WiFiManager;

impl WiFiManager {
/// Initialises the scheduler and the Wi-Fi radio, applies the station
/// configuration (which implicitly starts the controller and begins
/// association in `esp-radio 0.18`), and hands off control to
/// `embassy-net`.
/// Initialises the scheduler and the Wi-Fi radio, sets the station
/// credentials before the radio starts, and builds the `embassy-net`
/// stack — but does **not** initiate association.
///
/// # Readiness
///
/// Association is **initiated** before this function returns — but it
/// is **not awaited**. The function returns as soon as the controller
/// has been configured and the embassy-net stack has been built; the
/// radio is still negotiating with the AP at that moment, and DHCPv4
/// has not yet completed. Callers that need to know when the link
/// is usable must `await` [`AsyncWifiHandle::wait_for_ip`] (or watch
/// `Stack::wait_config_up` themselves). The spawned `wifi_task` only
/// needs to handle subsequent disconnects.
/// This function returns as soon as the controller has been configured
/// and the `embassy-net` stack has been built. No connection attempt
/// has been made yet. The caller is responsible for both the initial
/// association and all subsequent reconnects: spawn a `wifi_task` that
/// calls `controller.connect_async()` in a loop, then
/// `controller.wait_for_disconnect_async()` to block until the link
/// drops. Callers that need to know when DHCP has completed must
/// `await` [`AsyncWifiHandle::wait_for_ip`] (or poll
/// `Stack::wait_config_up` directly).
///
/// # Heap requirement
///
Expand Down Expand Up @@ -261,24 +261,24 @@ mod driver {
let sw_ints = SoftwareInterruptControl::new(config.sw_interrupt);
esp_rtos::start(timg.timer0, sw_ints.software_interrupt0);

// 2. Construct the Wi-Fi controller. In esp-radio 0.18 the radio
// init that used to be a separate `esp_radio::init()` call is
// folded into `wifi::new`; the function takes only the WIFI
// peripheral and a `ControllerConfig`.
let (mut controller, interfaces) =
esp_radio::wifi::new(config.wifi, ControllerConfig::default())
.map_err(WifiError::Driver)?;

// 3. Apply station mode + credentials. `set_config` is idempotent
// in 0.18 and implicitly starts the controller and initiates
// association — the explicit `start()`/`connect()` calls that
// existed in 0.17 are gone.
// 2. Build the station config and pass it as `initial_config` so that
// `esp_wifi_set_config` is called with real credentials BEFORE
// `esp_wifi_start()` fires inside `wifi::new`. Calling
// `set_config` a second time after start (the previous approach)
// does not reliably propagate credentials to the firmware on
// bare-metal — the IDF driver always sets config before start.
let station = StationConfig::default()
.with_ssid(config.ssid)
.with_password(config.password.into());
controller
.set_config(&Config::Station(station))
.map_err(WifiError::Driver)?;
let controller_cfg =
ControllerConfig::default().with_initial_config(Config::Station(station));

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

// 4. Power save (non-fatal if it fails).
let ps = map_power_save(config.power_save);
Expand All @@ -293,9 +293,21 @@ mod driver {
);
}

if config.password.is_empty() {
log::warn!(
"Wi-Fi password is empty — auth will fail on WPA2/WPA3 networks; \
set WIFI_PASS at build time (option_env! captures at compile time, not runtime)"
);
}

log::info!(
"Wi-Fi configured (SSID len={}), power save: {:?}",
"Wi-Fi configured (SSID len={}, password: {}), power save: {:?}",
config.ssid.len(),
if config.password.is_empty() {
"absent"
} else {
"present"
},
config.power_save,
);

Expand Down
1 change: 1 addition & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ timeline
title rustyfarian-network Roadmap

Ready : Finish hal_c3_connect_async hardware validation — AP reconnect loop + heap headroom (feature-doc)
: MQTT startup message — `.with_startup_message()` opt-in on MqttBuilder (feature-doc)

Near term : LoRa pure-side polish — LoraConfig builder + from_hex_strings Result return
: README 2D crate-status table — protocols × HAL tiers with maturity per cell
Expand Down
31 changes: 31 additions & 0 deletions docs/features/mqtt-startup-message-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Feature: MQTT Startup Message v1

## Decisions

| Decision | Reason | Rejected Alternative |
|-------------------------------------------------------------------------:|:-----------------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------|
| `.with_startup_message()` opt-in on `MqttBuilder` | Batteries-included: consumer opts in once, crate handles the rest on every (re)connect automatically | `MqttHandle::send_startup_message()` (manual call) — caller would need to wire it themselves in `on_connect` |
| Publish via `client.enqueue()` inside the builder's wrapped `on_connect` | `enqueue` is non-blocking and safe to call from a callback context | `MqttHandle::publish()` — holds the Mutex, deadlocks when called from inside a callback |
| Topic and payload hardcoded to `iot/{client_id}/startup` / `"1"` | Matches the old `MqttManager.send_startup_message()` convention; YAGNI — no consumer has requested customisation | Configurable topic/payload — deferred to v2 if a real need arises |
| Update `send_startup_message()` deprecation notice | Gives consumers a clear migration path to `.with_startup_message()` | Leave notice as-is — unhelpful without a pointer to the replacement |

## Constraints

- Must use `client.enqueue()` (passed into `on_connect`), not `MqttHandle::publish()` — the latter acquires the Mutex and deadlocks from a callback context.
- Topic must interpolate `client_id`, which is available from `MqttConfig` at `.build()` time — no runtime lookup needed.
- Must fire on every (re)connect, not just the first — consistent with `MqttBuilder`'s reconnect transparency.

## Open Questions

_(none)_

## State

- [x] Design approved
- [ ] Core implementation
- [ ] Tests passing
- [ ] Documentation updated

## Session Log

- 2026-05-12 — Feature doc created via /feature dialog
2 changes: 1 addition & 1 deletion docs/project-lore.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ Confirmed in `esp-radio-0.18.0/CHANGELOG.md` line 107 (`Support for the feature
- `WifiEvent::StaDisconnected` → `WifiEvent::StationDisconnected`
- `WifiError::Disconnected` is now a tuple variant `Disconnected(DisconnectedStationInfo)` — pattern matches that previously used the unit variant break
- `controller.is_connected()` returns `bool` directly, not `Result<bool, WifiError>`
- `controller.connect()`, `disconnect()`, `start()` (sync) — all removed; replacements are `connect_async().await`, `disconnect_async().await`; `set_config()` is now idempotent and implicitly starts the controller and begins association
- `controller.connect()`, `disconnect()`, `start()` (sync) — all removed; replacements are `connect_async().await`, `disconnect_async().await`; `set_config()` is now idempotent and starts the radio (`esp_wifi_start`) but does **not** initiate association — `connect_async()` must still be called explicitly
- `controller.wait_for_event(WifiEvent::StaDisconnected)` removed; replacement is `controller.wait_for_disconnect_async().await -> Result<DisconnectedStationInfo, WifiError>`
- `esp_radio::wifi::new()` signature is now `(WIFI<'d>, ControllerConfig)` — the prior `radio_ref` parameter is gone; `esp_radio::init()` is now `pub(crate)` and not part of user code

Expand Down
Loading