Skip to content

Commit 9efb1b2

Browse files
committed
Boy Scout Pass 2026-05-15
1 parent 8a8aa17 commit 9efb1b2

8 files changed

Lines changed: 137 additions & 67 deletions

File tree

AGENTS.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
66
## Project Overview
77

8-
`rustyfarian-network` is a Rust workspace providing Wi-Fi, MQTT, LoRa, and ESP-NOW networking libraries for ESP32 firmware.
8+
`rustyfarian-network` is a Rust workspace providing Wi-Fi, MQTT, LoRa, ESP-NOW, and OTA networking libraries for ESP32 firmware.
99
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).
1010
Both tiers share platform-independent `*-pure` crates that compile and unit-test on any host without the ESP toolchain.
1111

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

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

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

4647
## Key Conventions
4748

@@ -54,6 +55,13 @@ Pure crates iterate fast without the ESP toolchain — see `just check-wifi-pure
5455
- **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.
5556
- **License:** dual MIT / Apache-2.0 (see `LICENSE`).
5657

58+
## Coding Principles
59+
60+
- **State assumptions** before starting. If a task has multiple valid interpretations, present them rather than picking silently.
61+
- **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.
62+
- **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.
63+
- When your changes create orphans (unused imports, variables, functions), remove them. Do not remove pre-existing dead code unless asked.
64+
5765
## Important Files
5866

5967
| File | Why read it |

crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async.rs

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use embassy_executor::Spawner;
3030
use embassy_time::{Duration, Timer};
3131
use esp_backtrace as _;
3232
use esp_println::println;
33-
use esp_radio::wifi::{Interface, WifiController};
33+
use esp_radio::wifi::{scan::ScanConfig, Interface, WifiController};
3434
use rustyfarian_esp_hal_wifi::{AsyncWifiHandle, WiFiConfig, WiFiConfigExt, WiFiManager};
3535

3636
esp_bootloader_esp_idf::esp_app_desc!();
@@ -73,11 +73,24 @@ async fn main(spawner: Spawner) {
7373
// Destructure: `stack` is `Copy`, so we keep our own copy before moving
7474
// `controller` and `runner` into their tasks.
7575
let AsyncWifiHandle {
76-
controller,
76+
mut controller,
7777
stack,
7878
runner,
7979
} = handle;
8080

81+
// Scan before connecting — mirrors the official embassy_dhcp example.
82+
// The active scan lets the radio settle and builds its BSSID/channel cache
83+
// before the first association attempt.
84+
println!("Scanning...");
85+
match controller.scan_async(&ScanConfig::default()).await {
86+
Ok(aps) => {
87+
for ap in &aps {
88+
println!(" {:?}", ap);
89+
}
90+
}
91+
Err(e) => println!("Scan failed (continuing anyway): {:?}", e),
92+
}
93+
8194
spawner.spawn(wifi_task(controller).unwrap());
8295
spawner.spawn(net_task(runner).unwrap());
8396

@@ -97,20 +110,23 @@ async fn main(spawner: Spawner) {
97110
}
98111
}
99112

100-
// Initial association is started by `WiFiManager::init_async`; this task
101-
// only handles reconnection after a disconnect event.
113+
// Handles both the initial association and any subsequent reconnects.
114+
// `set_config` starts the radio but does NOT initiate association in
115+
// esp-radio 0.18 — `connect_async` must always be called explicitly.
102116
#[embassy_executor::task]
103117
async fn wifi_task(mut controller: WifiController<'static>) {
104-
// `wait_for_disconnect_async` + `connect_async` replace the sync
105-
// `wait_for_event(StaDisconnected)` + `connect` pair removed in
106-
// esp-radio 0.18.
107118
loop {
108-
let _ = controller.wait_for_disconnect_async().await;
109-
println!("Wi-Fi disconnected — attempting to reconnect");
110-
Timer::after(Duration::from_millis(500)).await;
111-
if let Err(e) = controller.connect_async().await {
112-
println!("reconnect failed: {:?}", e);
119+
match controller.connect_async().await {
120+
Ok(_) => {
121+
// Connected — block until the link drops.
122+
let _ = controller.wait_for_disconnect_async().await;
123+
println!("Wi-Fi disconnected — reconnecting...");
124+
}
125+
Err(e) => {
126+
println!("connect failed: {:?}", e);
127+
}
113128
}
129+
Timer::after(Duration::from_millis(500)).await;
114130
}
115131
}
116132

crates/rustyfarian-esp-hal-wifi/examples/hal_c3_connect_async_led.rs

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -148,22 +148,23 @@ async fn led_task(mut led: Output<'static>) {
148148
}
149149
}
150150

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

crates/rustyfarian-esp-hal-wifi/examples/hal_c6_connect_async_led.rs

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -173,22 +173,23 @@ async fn led_task(mut led: Ws2812Rmt<'static, Blocking, N>) {
173173
}
174174
}
175175

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

crates/rustyfarian-esp-hal-wifi/src/lib.rs

Lines changed: 40 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -203,21 +203,21 @@ mod driver {
203203
pub struct WiFiManager;
204204

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

264-
// 2. Construct the Wi-Fi controller. In esp-radio 0.18 the radio
265-
// init that used to be a separate `esp_radio::init()` call is
266-
// folded into `wifi::new`; the function takes only the WIFI
267-
// peripheral and a `ControllerConfig`.
268-
let (mut controller, interfaces) =
269-
esp_radio::wifi::new(config.wifi, ControllerConfig::default())
270-
.map_err(WifiError::Driver)?;
271-
272-
// 3. Apply station mode + credentials. `set_config` is idempotent
273-
// in 0.18 and implicitly starts the controller and initiates
274-
// association — the explicit `start()`/`connect()` calls that
275-
// existed in 0.17 are gone.
264+
// 2. Build the station config and pass it as `initial_config` so that
265+
// `esp_wifi_set_config` is called with real credentials BEFORE
266+
// `esp_wifi_start()` fires inside `wifi::new`. Calling
267+
// `set_config` a second time after start (the previous approach)
268+
// does not reliably propagate credentials to the firmware on
269+
// bare-metal — the IDF driver always sets config before start.
276270
let station = StationConfig::default()
277271
.with_ssid(config.ssid)
278272
.with_password(config.password.into());
279-
controller
280-
.set_config(&Config::Station(station))
281-
.map_err(WifiError::Driver)?;
273+
let controller_cfg =
274+
ControllerConfig::default().with_initial_config(Config::Station(station));
275+
276+
// 3. Construct the Wi-Fi controller. `wifi::new` applies
277+
// `initial_config` (our real credentials) then calls
278+
// `esp_wifi_start()` — credentials are set before the radio
279+
// starts, matching the IDF init sequence.
280+
let (mut controller, interfaces) =
281+
esp_radio::wifi::new(config.wifi, controller_cfg).map_err(WifiError::Driver)?;
282282

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

296+
if config.password.is_empty() {
297+
log::warn!(
298+
"Wi-Fi password is empty — auth will fail on WPA2/WPA3 networks; \
299+
set WIFI_PASS at build time (option_env! captures at compile time, not runtime)"
300+
);
301+
}
302+
296303
log::info!(
297-
"Wi-Fi configured (SSID len={}), power save: {:?}",
304+
"Wi-Fi configured (SSID len={}, password: {}), power save: {:?}",
298305
config.ssid.len(),
306+
if config.password.is_empty() {
307+
"absent"
308+
} else {
309+
"present"
310+
},
299311
config.power_save,
300312
);
301313

docs/ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ timeline
2626
title rustyfarian-network Roadmap
2727
2828
Ready : Finish hal_c3_connect_async hardware validation — AP reconnect loop + heap headroom (feature-doc)
29+
: MQTT startup message — `.with_startup_message()` opt-in on MqttBuilder (feature-doc)
2930
3031
Near term : LoRa pure-side polish — LoraConfig builder + from_hex_strings Result return
3132
: README 2D crate-status table — protocols × HAL tiers with maturity per cell
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Feature: MQTT Startup Message v1
2+
3+
## Decisions
4+
5+
| Decision | Reason | Rejected Alternative |
6+
|-------------------------------------------------------------------------:|:-----------------------------------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------|
7+
| `.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` |
8+
| 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 |
9+
| 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 |
10+
| 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 |
11+
12+
## Constraints
13+
14+
- Must use `client.enqueue()` (passed into `on_connect`), not `MqttHandle::publish()` — the latter acquires the Mutex and deadlocks from a callback context.
15+
- Topic must interpolate `client_id`, which is available from `MqttConfig` at `.build()` time — no runtime lookup needed.
16+
- Must fire on every (re)connect, not just the first — consistent with `MqttBuilder`'s reconnect transparency.
17+
18+
## Open Questions
19+
20+
_(none)_
21+
22+
## State
23+
24+
- [x] Design approved
25+
- [ ] Core implementation
26+
- [ ] Tests passing
27+
- [ ] Documentation updated
28+
29+
## Session Log
30+
31+
- 2026-05-12 — Feature doc created via /feature dialog

docs/project-lore.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ Confirmed in `esp-radio-0.18.0/CHANGELOG.md` line 107 (`Support for the feature
107107
- `WifiEvent::StaDisconnected``WifiEvent::StationDisconnected`
108108
- `WifiError::Disconnected` is now a tuple variant `Disconnected(DisconnectedStationInfo)` — pattern matches that previously used the unit variant break
109109
- `controller.is_connected()` returns `bool` directly, not `Result<bool, WifiError>`
110-
- `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
110+
- `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
111111
- `controller.wait_for_event(WifiEvent::StaDisconnected)` removed; replacement is `controller.wait_for_disconnect_async().await -> Result<DisconnectedStationInfo, WifiError>`
112112
- `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
113113

0 commit comments

Comments
 (0)