Skip to content

Commit 6c2f1c4

Browse files
committed
Add burst timeout configuration for peer discovery in ScanConfig
- Introduced `DEFAULT_BURST_TIMEOUT` to limit the total time spent at boosted TX power during peer discovery. - Updated `ScanConfig` to include a `burst_timeout` field and a method to override it. - Enhanced the scanning logic to respect the burst timeout, stopping early if the limit is reached. - Documented the new burst timeout behavior and its implications for TX power management.
1 parent b491ec4 commit 6c2f1c4

6 files changed

Lines changed: 92 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
### Added
1515

1616
- `espnow-pure`: `ScanConfig::with_probe_timeout()` and `DEFAULT_PROBE_TIMEOUT` (100 ms) — per-channel probe timeout is now configurable
17+
- `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
1718
- `wifi-pure`: `TxPowerLevel` enum (`Lowest`, `Low`, `Medium`, `High`, `Max`) with `to_quarter_dbm()` mapping to ESP-IDF quarter-dBm values; `WiFiConfig::with_tx_power()` builder method (see `docs/features/wifi-radio-power-config-v1.md`)
1819
- `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
1920
- `rustyfarian-network-pure`: `status_colors` module with shared LED colour palette (`BOOT`, `WIFI_CONNECTING`, `MQTT_CONNECTING`, `CONNECTED`, `ERROR`, `OFFLINE`)

crates/espnow-pure/src/lib.rs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ pub const DEFAULT_SCAN_CHANNELS: [u8; 13] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
4343
/// [`ScanConfig::with_probe_timeout`].
4444
pub const DEFAULT_PROBE_TIMEOUT: core::time::Duration = core::time::Duration::from_millis(100);
4545

46+
/// Default total burst timeout for a peer scan.
47+
///
48+
/// Caps the wall-clock time the radio spends at boosted TX power during a
49+
/// scan, regardless of how many channels remain. At default settings
50+
/// (13 channels × 100 ms probe) the loop already finishes in ~1.3 s; this
51+
/// only kicks in for long custom probe timeouts or large channel lists.
52+
/// Override with [`ScanConfig::with_burst_timeout`].
53+
pub const DEFAULT_BURST_TIMEOUT: core::time::Duration = core::time::Duration::from_secs(3);
54+
4655
// ─── Types ───────────────────────────────────────────────────────────────────
4756

4857
/// A 6-byte IEEE 802.11 MAC address.
@@ -175,16 +184,23 @@ pub struct ScanConfig<'a> {
175184
pub probe_data: &'a [u8],
176185
/// Per-channel probe timeout (default: [`DEFAULT_PROBE_TIMEOUT`] = 100 ms).
177186
pub probe_timeout: core::time::Duration,
187+
/// Total burst timeout (default: [`DEFAULT_BURST_TIMEOUT`] = 3 s).
188+
///
189+
/// The scan loop checks elapsed time before each channel and stops
190+
/// early once this limit is reached. Bounds the time the radio
191+
/// spends at boosted TX power during peer discovery.
192+
pub burst_timeout: core::time::Duration,
178193
}
179194

180195
impl<'a> ScanConfig<'a> {
181196
/// Create a scan config with default channels (1-13), the given probe
182-
/// payload, and the default probe timeout.
197+
/// payload, the default probe timeout, and the default burst timeout.
183198
pub fn new(probe_data: &'a [u8]) -> Self {
184199
Self {
185200
channels: &DEFAULT_SCAN_CHANNELS,
186201
probe_data,
187202
probe_timeout: DEFAULT_PROBE_TIMEOUT,
203+
burst_timeout: DEFAULT_BURST_TIMEOUT,
188204
}
189205
}
190206

@@ -199,6 +215,16 @@ impl<'a> ScanConfig<'a> {
199215
self.probe_timeout = timeout;
200216
self
201217
}
218+
219+
/// Override the total burst timeout.
220+
///
221+
/// The scan loop stops early once this elapsed time is reached, even
222+
/// if channels remain unprobed. Use this to cap how long the radio
223+
/// runs at boosted TX power if discovery fails.
224+
pub fn with_burst_timeout(mut self, timeout: core::time::Duration) -> Self {
225+
self.burst_timeout = timeout;
226+
self
227+
}
202228
}
203229

204230
// ─── ScanResult ─────────────────────────────────────────────────────────────
@@ -368,6 +394,7 @@ mod tests {
368394
assert_eq!(config.channels, &DEFAULT_SCAN_CHANNELS);
369395
assert_eq!(config.probe_data, b"probe");
370396
assert_eq!(config.probe_timeout, DEFAULT_PROBE_TIMEOUT);
397+
assert_eq!(config.burst_timeout, DEFAULT_BURST_TIMEOUT);
371398
}
372399

373400
#[test]
@@ -384,6 +411,13 @@ mod tests {
384411
assert_eq!(config.probe_timeout, timeout);
385412
}
386413

414+
#[test]
415+
fn scan_config_custom_burst_timeout() {
416+
let timeout = core::time::Duration::from_secs(5);
417+
let config = ScanConfig::new(b"ping").with_burst_timeout(timeout);
418+
assert_eq!(config.burst_timeout, timeout);
419+
}
420+
387421
#[test]
388422
fn scan_result_equality() {
389423
assert_eq!(ScanResult { channel: 6 }, ScanResult { channel: 6 });

crates/rustyfarian-esp-idf-espnow/src/lib.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,14 @@ impl EspIdfEspNow {
226226
Ok((driver, result))
227227
}
228228

229+
/// Scan the channels in `config` for the peer at `mac`.
230+
///
231+
/// Note on radio side-effects: TX power is a global radio setting.
232+
/// While the burst is active, any concurrent Wi-Fi or ESP-NOW
233+
/// transmissions on this chip also go out at the boosted level.
234+
/// On dual-radio (Wi-Fi + ESP-NOW on the same chip) deployments,
235+
/// schedule scans during quiet periods if predictable per-frame
236+
/// power matters.
229237
fn scan_channels(
230238
&self,
231239
mac: &MacAddress,
@@ -257,7 +265,19 @@ impl EspIdfEspNow {
257265
}
258266

259267
let scan_result = (|| -> anyhow::Result<ScanResult> {
268+
let burst_start = Instant::now();
269+
let mut probed = 0usize;
270+
260271
for &channel in config.channels {
272+
if burst_start.elapsed() >= config.burst_timeout {
273+
log::debug!(
274+
"Burst timeout {:?} reached after {} channels; stopping scan",
275+
config.burst_timeout,
276+
probed
277+
);
278+
break;
279+
}
280+
261281
log::debug!("Probing channel {} for peer {:02X?}", channel, mac);
262282

263283
// SAFETY: esp_wifi_set_channel is an FFI call into the ESP-IDF
@@ -280,6 +300,8 @@ impl EspIdfEspNow {
280300
continue;
281301
}
282302

303+
probed += 1;
304+
283305
match ack_status.wait(config.probe_timeout) {
284306
Some(true) => return Ok(ScanResult { channel }),
285307
Some(false) => log::debug!("No ACK on channel {}", channel),
@@ -288,7 +310,8 @@ impl EspIdfEspNow {
288310
}
289311

290312
anyhow::bail!(
291-
"peer not found on any of the {} scanned channels",
313+
"peer not found after probing {} of {} configured channels",
314+
probed,
292315
config.channels.len()
293316
)
294317
})();

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -234,13 +234,21 @@ impl WiFiManager {
234234
log::info!("WiFi started, power save: {:?}", config.power_save);
235235

236236
let tx_power = config.tx_power.to_quarter_dbm();
237-
esp_idf_svc::sys::esp!(unsafe { esp_idf_svc::sys::esp_wifi_set_max_tx_power(tx_power) })
238-
.context("failed to set WiFi TX power")?;
239-
log::info!(
240-
"WiFi TX power set to {:?} ({} quarter-dBm)",
241-
config.tx_power,
242-
tx_power
243-
);
237+
match esp_idf_svc::sys::esp!(unsafe {
238+
esp_idf_svc::sys::esp_wifi_set_max_tx_power(tx_power)
239+
}) {
240+
Ok(()) => log::info!(
241+
"WiFi TX power set to {:?} ({} quarter-dBm)",
242+
config.tx_power,
243+
tx_power
244+
),
245+
Err(e) => log::warn!(
246+
"Failed to set WiFi TX power to {:?} ({} quarter-dBm), continuing at radio default: {:?}",
247+
config.tx_power,
248+
tx_power,
249+
e
250+
),
251+
}
244252

245253
// In non-blocking mode, subscribe to disconnect events so failures such as
246254
// WIFI_REASON_NO_AP_FOUND are visible at WARN level without enabling debug logs.

crates/wifi-pure/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ pub enum WifiPowerSave {
123123
/// | `Medium` | 52 | 13 |
124124
/// | `High` | 68 | 17 |
125125
/// | `Max` | 78 | 19.5 |
126+
///
127+
/// These values are heuristic defaults intended to span the usable range of
128+
/// supported ESP32 chips.
129+
/// Local regulatory limits, antenna design, and per-board layout may require
130+
/// lower settings — pick the lowest level that meets your range needs.
131+
/// The backend may also clamp the requested value to the chip's effective
132+
/// maximum, so the actual radiated power can differ from the table above.
126133
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
127134
pub enum TxPowerLevel {
128135
/// Minimum transmit power (~2 dBm). Best for close-range, low-heat use.

docs/features/wifi-radio-power-config-v1.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22

33
## Decisions
44

5-
| Decision | Reason | Rejected Alternative |
6-
|---------------------------------------------------------------------------------------------------:|:----------------------------------------------------------------------|:-------------------------------------------------------------------------------------|
7-
| 5-level enum for TX power (`Lowest`, `Low`, `Medium`, `High`, `Max`) | Intuitive for users; abstracts raw dBm values which vary by chip | Raw dBm integer — too low-level, error-prone, chip-dependent limits |
8-
| 5-level enum for power-save mode reusing the same scale concept | Consistent API; lowest maps to `MinModem`, highest maps to `MaxModem` | Exposing ESP-IDF `WifiPowerSave` directly — leaks platform detail |
9-
| Configuration at init time only (builder API) | Simplest correct approach; runtime adjustment can be added later | Runtime-adjustable — unnecessary complexity for current use case |
10-
| Auto-burst during discovery: full TX power during `scan_for_peer()`, then drop to configured level | Maximizes discovery range without permanent heat; no manual toggling | Always-high power — defeats the purpose; manual toggle — error-prone, easy to forget |
11-
| Discovery burst has a configurable timeout (default a few seconds) | Prevents staying at full power if peer never comes online | No timeout — risks sustained high power indefinitely on failed discovery |
12-
| Exact dBm mapping per level determined during implementation | Requires testing on real hardware across C3/C6/S3 | Guessing values upfront — unreliable without measurement |
13-
| Shared enum types in `wifi-pure` (platform-independent crate) | Keeps types testable and reusable across ESP-IDF and esp-hal backends | Defining in each HAL crate — duplication, divergent APIs |
5+
| Decision | Reason | Rejected Alternative |
6+
|--------------------------------------------------------------------------------------------------------:|:------------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------|
7+
| 5-level enum for TX power (`Lowest`, `Low`, `Medium`, `High`, `Max`) | Intuitive for users; abstracts raw dBm values which vary by chip | Raw dBm integer — too low-level, error-prone, chip-dependent limits |
8+
| Configuration at init time only (builder API) | Simplest correct approach; runtime adjustment can be added later | Runtime-adjustable — unnecessary complexity for current use case |
9+
| TX power apply failure is non-fatal (warn + continue) | Matches existing `power_save` handling; tuning, not correctness | Returning an error — would block Wi-Fi start on a tuning preference |
10+
| Auto-burst during discovery: full TX power during `scan_for_peer()`, then drop to configured level | Maximizes discovery range without permanent heat; no manual toggling | Always-high power — defeats the purpose; manual toggle — error-prone, easy to forget |
11+
| Burst bounded by an explicit `burst_timeout` (3 s default) | Prevents staying at full power if peer never comes online; checked between channels | Implicit bound from `channels × probe_timeout` — drifts with custom configs |
12+
| Exact dBm mapping per level determined during implementation | Requires testing on real hardware across C3/C6/S3 | Guessing values upfront — unreliable without measurement |
13+
| Shared enum types in `wifi-pure` (platform-independent crate) | Keeps types testable and reusable across ESP-IDF and esp-hal backends | Defining in each HAL crate — duplication, divergent APIs |
1414

1515
## Constraints
1616

@@ -37,3 +37,4 @@
3737
- 2026-04-03 — Feature doc created via /feature dialog
3838
- 2026-04-03 — Added auto-burst during discovery with timeout
3939
- 2026-04-10 — Implemented: `TxPowerLevel` enum in `wifi-pure` with 5 levels and `to_quarter_dbm()` mapping. ESP-IDF backend calls `esp_wifi_set_max_tx_power()` after `wifi.start()`. esp-hal backend stores config but logs warning (esp-radio 0.17 lacks TX power API). ESP-NOW `scan_for_peer()` auto-bursts to max TX power during scanning with save/restore. 24 wifi-pure tests pass including 6 new TxPowerLevel tests. `just verify` and `just build-example` (hal_c3_connect, hal_c3_connect_async) all pass clean.
40+
- 2026-05-05 — Review follow-ups: dropped unimplemented "5-level power-save enum" decision row (PR reuses `WifiPowerSave`); added explicit `burst_timeout` (3 s default) to `ScanConfig` with early break in scan loop; converted ESP-IDF TX power apply failure to warn-and-continue (matches existing `power_save` handling); added regulatory/clamping note to `TxPowerLevel` docstring; added concurrency note to `scan_channels()`; replaced hardcoded burst value with `wifi_pure::TxPowerLevel::Max.to_quarter_dbm()` to remove drift risk.

0 commit comments

Comments
 (0)