Skip to content

Commit dc142f1

Browse files
committed
Add ESP-NOW radio management and builder methods for peer configuration
1 parent 4fb1a52 commit dc142f1

5 files changed

Lines changed: 165 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- `rustyfarian-esp-idf-mqtt`: non-blocking `MqttHandle::try_publish`, `try_publish_retained`, and `try_publish_with` with `TryPublishError` for time-critical loops
1313
- `wifi-pure`: `WifiPowerSave` enum (`None`, `MinModem`, `MaxModem`) and `WiFiConfig::with_power_save()` builder method
1414
- `rustyfarian-esp-idf-wifi`: applies configured power save mode via `esp_wifi_set_ps()` after Wi-Fi start
15+
- `rustyfarian-esp-idf-espnow`: `EspIdfEspNow::init_with_radio()` starts and owns the Wi-Fi radio for ESP-NOW-only devices (ADR 008)
16+
- `rustyfarian-esp-idf-espnow`: `EspIdfEspNow::default_interface()` returns the correct `WifiInterface` based on init mode
17+
- `espnow-pure`: `PeerConfig::with_ap_interface()` builder method for ESP-NOW-only devices
1518

1619
## [0.1.0] - 2026-03-16
1720

crates/espnow-pure/src/lib.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,14 @@ impl PeerConfig {
132132
interface: WifiInterface::Sta,
133133
}
134134
}
135+
136+
/// Sets the Wi-Fi interface to [`WifiInterface::Ap`].
137+
///
138+
/// Use this for peers on ESP-NOW-only devices that have no STA connection.
139+
pub fn with_ap_interface(mut self) -> Self {
140+
self.interface = WifiInterface::Ap;
141+
self
142+
}
135143
}
136144

137145
// ─── EspNowDriver trait ──────────────────────────────────────────────────────
@@ -220,12 +228,11 @@ mod tests {
220228
#[test]
221229
fn peer_config_with_ap_interface() {
222230
let mac = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06];
223-
let config = PeerConfig {
224-
interface: WifiInterface::Ap,
225-
..PeerConfig::new(mac)
226-
};
231+
let config = PeerConfig::new(mac).with_ap_interface();
227232
assert_eq!(config.interface, WifiInterface::Ap);
228233
assert_eq!(config.mac, mac);
234+
assert_eq!(config.channel, 0);
235+
assert!(!config.encrypt);
229236
}
230237

231238
#[test]

crates/rustyfarian-esp-idf-espnow/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ embuild.workspace = true
1616
espnow-pure.workspace = true
1717
anyhow.workspace = true
1818
esp-idf-svc.workspace = true
19+
esp-idf-hal.workspace = true
1920
log.workspace = true

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

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
1616

1717
use anyhow::Context as _;
1818
use esp_idf_svc::espnow::{EspNow, PeerInfo};
19+
use esp_idf_svc::hal::modem::Modem;
20+
use esp_idf_svc::wifi::EspWifi;
1921
pub use espnow_pure::{
2022
EspNowDriver, EspNowEvent, MacAddress, PeerConfig, WifiInterface, BROADCAST_MAC,
2123
DEFAULT_RX_CHANNEL_CAPACITY, MAX_DATA_LEN,
@@ -25,22 +27,75 @@ pub use espnow_pure::{
2527
///
2628
/// Wraps [`EspNow<'static>`] and bridges the C receive callback into a
2729
/// [`std::sync::mpsc::sync_channel`] for non-blocking polling.
30+
///
31+
/// # Radio management
32+
///
33+
/// - [`init()`](EspIdfEspNow::init) — caller owns the Wi-Fi radio;
34+
/// the radio must already be started before calling this.
35+
/// - [`init_with_radio()`](EspIdfEspNow::init_with_radio) — the driver
36+
/// starts and owns the radio internally. Use this for ESP-NOW-only devices
37+
/// that do not connect to a Wi-Fi AP.
2838
pub struct EspIdfEspNow {
2939
esp_now: EspNow<'static>,
3040
rx: Receiver<EspNowEvent>,
41+
_wifi: Option<EspWifi<'static>>,
3142
}
3243

3344
impl EspIdfEspNow {
3445
/// Initialise ESP-NOW with the default receive-queue capacity of
3546
/// [`DEFAULT_RX_CHANNEL_CAPACITY`] frames.
47+
///
48+
/// The Wi-Fi radio must already be started by the caller.
49+
/// For ESP-NOW-only devices, use [`init_with_radio()`](Self::init_with_radio) instead.
3650
pub fn init() -> anyhow::Result<Self> {
37-
Self::init_with_capacity(DEFAULT_RX_CHANNEL_CAPACITY)
51+
Self::init_inner(DEFAULT_RX_CHANNEL_CAPACITY, None)
3852
}
3953

4054
/// Initialise ESP-NOW with a custom receive-queue capacity.
4155
///
56+
/// The Wi-Fi radio must already be started by the caller.
4257
/// Frames received while the queue is full are dropped with a warning log.
4358
pub fn init_with_capacity(capacity: usize) -> anyhow::Result<Self> {
59+
Self::init_inner(capacity, None)
60+
}
61+
62+
/// Initialise ESP-NOW and start the Wi-Fi radio internally.
63+
///
64+
/// Use this for devices that need ESP-NOW without connecting to a Wi-Fi AP.
65+
/// The radio is kept alive for the lifetime of the returned driver.
66+
///
67+
/// Peers should use [`WifiInterface::Ap`] (or call
68+
/// [`PeerConfig::with_ap_interface()`]) since there is no STA connection.
69+
/// See [`default_interface()`](Self::default_interface).
70+
pub fn init_with_radio(
71+
modem: Modem<'static>,
72+
sys_loop: esp_idf_svc::eventloop::EspSystemEventLoop,
73+
nvs: Option<esp_idf_svc::nvs::EspDefaultNvsPartition>,
74+
) -> anyhow::Result<Self> {
75+
let mut wifi = EspWifi::new(modem, sys_loop, nvs)
76+
.context("failed to create EspWifi for ESP-NOW radio")?;
77+
wifi.start()
78+
.context("failed to start Wi-Fi radio for ESP-NOW")?;
79+
log::info!("Wi-Fi radio started for ESP-NOW (no AP connection)");
80+
81+
Self::init_inner(DEFAULT_RX_CHANNEL_CAPACITY, Some(wifi))
82+
}
83+
84+
/// Returns the recommended [`WifiInterface`] for peer configuration.
85+
///
86+
/// - [`WifiInterface::Ap`] when the driver owns the radio
87+
/// (created via [`init_with_radio()`](Self::init_with_radio) — no STA connection)
88+
/// - [`WifiInterface::Sta`] when the caller manages Wi-Fi
89+
/// (created via [`init()`](Self::init) — STA is assumed)
90+
pub fn default_interface(&self) -> WifiInterface {
91+
if self._wifi.is_some() {
92+
WifiInterface::Ap
93+
} else {
94+
WifiInterface::Sta
95+
}
96+
}
97+
98+
fn init_inner(capacity: usize, wifi: Option<EspWifi<'static>>) -> anyhow::Result<Self> {
4499
let esp_now = EspNow::take().context("failed to acquire EspNow singleton")?;
45100

46101
let (tx, rx): (SyncSender<EspNowEvent>, Receiver<EspNowEvent>) = sync_channel(capacity);
@@ -58,7 +113,11 @@ impl EspIdfEspNow {
58113
})
59114
.context("failed to register ESP-NOW receive callback")?;
60115

61-
Ok(Self { esp_now, rx })
116+
Ok(Self {
117+
esp_now,
118+
rx,
119+
_wifi: wifi,
120+
})
62121
}
63122
}
64123

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# ADR 008: ESP-NOW Radio-Only Initialisation
2+
3+
## Status
4+
5+
Accepted
6+
7+
## Context
8+
9+
Devices that use ESP-NOW without connecting to a Wi-Fi access point (e.g. LED matrix, nunchuck controller) must manually initialise the Wi-Fi radio before calling `EspIdfEspNow::init()`.
10+
Every consumer writes the same boilerplate:
11+
12+
```rust
13+
let mut wifi = EspWifi::new(peripherals.modem, sys_loop, Some(nvs))?;
14+
wifi.start()?;
15+
let espnow = EspIdfEspNow::init()?;
16+
```
17+
18+
Additionally, ESP-NOW-only devices must manually override `PeerConfig::interface` from the default `WifiInterface::Sta` to `WifiInterface::Ap`, because there is no STA connection.
19+
This is a known ESP-NOW quirk that catches every new consumer and wastes debugging time.
20+
21+
Evidence from downstream firmware:
22+
- `firmware-rgb-matrix/src/main.rs` — raw `EspWifi::new()` + `.start()`
23+
- `firmware-rgb-nunchuck/src/main.rs` — same pattern + manual `brain_peer.interface = WifiInterface::Ap`
24+
25+
### Design decisions under evaluation
26+
27+
**Where to store the radio**
28+
29+
`init_with_radio()` must keep the `EspWifi` instance alive — if dropped, the radio shuts down and ESP-NOW stops working.
30+
Two options: a separate wrapper struct, or an `Option<EspWifi<'static>>` field on the existing `EspIdfEspNow`.
31+
A separate struct would duplicate the `EspNowDriver` trait implementation.
32+
33+
**WifiInterface auto-detection**
34+
35+
Consumers who use `init_with_radio()` (no STA connection) always need `WifiInterface::Ap` on their peers.
36+
The driver knows whether it owns the radio, so it can expose a `default_interface()` method to guide callers.
37+
38+
**PeerConfig ergonomics**
39+
40+
`PeerConfig` in `espnow-pure` defaults `interface` to `Sta`.
41+
A builder method for the AP case would eliminate the field-level override that every ESP-NOW-only device needs.
42+
43+
## Decision
44+
45+
Add `init_with_radio()` to `EspIdfEspNow` and a `with_ap_interface()` builder to `PeerConfig`.
46+
Leave the existing `init()` unchanged for devices that manage Wi-Fi themselves.
47+
48+
### Changes to `rustyfarian-esp-idf-espnow`
49+
50+
- Add `_wifi: Option<EspWifi<'static>>` field to `EspIdfEspNow`.
51+
Existing `init()` sets it to `None`; `init_with_radio()` stores `Some(wifi)`.
52+
53+
- Add `init_with_radio(modem, sys_loop, nvs)` method that:
54+
1. Creates `EspWifi::new(modem, sys_loop, nvs)` and calls `.start()`
55+
2. Stores the `EspWifi` instance to keep the radio alive
56+
3. Delegates to the existing `init_with_capacity()` logic for ESP-NOW setup
57+
58+
- Add `default_interface()` method returning `WifiInterface::Ap` when the driver owns the radio, `WifiInterface::Sta` otherwise.
59+
60+
- Add `esp-idf-hal` workspace dependency to `Cargo.toml` (for `Modem` type).
61+
62+
### Changes to `espnow-pure`
63+
64+
- Add `PeerConfig::with_ap_interface()` builder method — sets `interface` to `WifiInterface::Ap`.
65+
66+
### What stays unchanged
67+
68+
- `EspNowDriver` trait — radio init is a platform concern, not a driver operation.
69+
- `WifiInterface` enum — no new variants needed.
70+
- Existing `init()` / `init_with_capacity()` signatures — fully backwards compatible.
71+
72+
## Consequences
73+
74+
### Positive
75+
76+
- **Eliminates boilerplate** — ESP-NOW-only firmware drops ~8 lines of manual radio init per crate
77+
- **Eliminates the `WifiInterface::Ap` gotcha**`default_interface()` + `with_ap_interface()` make the correct choice obvious
78+
- **Backwards compatible** — existing `init()` callers are unaffected
79+
- **Radio lifetime is safe**`EspWifi` stored in the struct prevents accidental drop
80+
81+
### Negative
82+
83+
- **`EspIdfEspNow` grows an `Option` field** — minor size increase; `None` path has no overhead
84+
- **New dependency**`esp-idf-hal` added to `rustyfarian-esp-idf-espnow` (already a transitive dep via `esp-idf-svc`)
85+
86+
## References
87+
88+
- [ADR 007 — ESP-NOW Abstraction Layer](007-espnow-abstraction.md)
89+
- Feature request: `review-queue/espnow-radio-only-init.md`

0 commit comments

Comments
 (0)