m5stack_core/driver/radio/wifi.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! WiFi sub-function of the shared radio.
3//!
4//! Layered, fully-async surface built on `esp-radio`:
5//!
6//! - **`wifi` feature** — [`Wifi`]: owns the `WifiController` and exposes
7//! on-demand scanning ([`Wifi::scan`]). No IP stack, no `embassy-net`.
8//! - **`wifi-sta` feature** — [`Wifi::into_sta`]: consumes the handle and returns
9//! a DHCP/static `embassy_net::Stack`, a [`WifiControl`] command handle, and a
10//! [`WifiRunner`] you spawn via [`wifi_task`]. The runner owns the single
11//! controller and manages association (auto-connect + reconnect), so scanning
12//! while associated is routed through [`WifiControl::scan`] — there is never
13//! more than one owner of the controller.
14//!
15//! ## Mode roadmap
16//!
17//! Only station (STA) is wired today. The actor in [`wifi_task`] is the single
18//! controller owner, so AP mode (`wifi-ap`) slots in as an `into_ap` constructor
19//! plus a `Config::AccessPoint`; ESP-NOW / sniffer / CSI are separate esp-radio
20//! features layered the same way.
21//!
22//! ## Variant differences (Fire27 / ESP32 vs CoreS3 / ESP32-S3)
23//!
24//! The esp-radio WiFi API is identical on both chips; the differences are RAM:
25//! - The RX/TX buffer counts in [`controller_config`] are trimmed on Fire27
26//! (ESP32) where internal SRAM is tighter, and left at the esp-radio defaults
27//! on CoreS3 (ESP32-S3).
28//! - **Fire27 cannot DMA out of PSRAM** — the application must keep the
29//! `embassy_net::StackResources` and socket buffers in internal RAM. CoreS3 has
30//! headroom and may place large app buffers in PSRAM (see [`crate::mem`]).
31//! - With BLE coexistence (`coex`), budget the extra controller heap (~96 KB
32//! reclaimed on ESP32, less on S3).
33
34use esp_hal::peripherals::WIFI;
35use esp_radio::wifi::{ControllerConfig, Interfaces, WifiController, scan::ScanConfig};
36use thiserror_no_std::Error;
37
38pub use esp_radio::wifi::{AuthenticationMethod, ap::AccessPointInfo};
39
40/// Scan results, capped at 16 access points.
41pub type ScanList = heapless::Vec<AccessPointInfo, 16>;
42
43/// Errors from the WiFi sub-system.
44#[derive(Debug, Error)]
45pub enum WifiError {
46 /// Error reported by the underlying esp-radio WiFi controller.
47 #[error("WiFi controller error")]
48 Controller(#[from] esp_radio::wifi::WifiError),
49}
50
51/// Low-level WiFi handle: owns the controller and its interfaces.
52///
53/// Create with [`Wifi::new`] for scan-only use, or call [`Wifi::into_sta`]
54/// (requires `wifi-sta`) to bring up a station with an `embassy_net::Stack`.
55pub struct Wifi {
56 controller: WifiController<'static>,
57 // Only consumed by `into_sta` (the `wifi-sta` feature); scan-only builds hold
58 // it for the station device but never read it.
59 #[cfg_attr(not(feature = "wifi-sta"), allow(dead_code))]
60 interfaces: Interfaces<'static>,
61}
62
63impl Wifi {
64 /// Create and start the WiFi controller with a chip-tuned configuration.
65 ///
66 /// `esp_rtos::start(..)` must already be running.
67 pub fn new(wifi: WIFI<'static>) -> Result<Self, WifiError> {
68 let (controller, interfaces) = esp_radio::wifi::new(wifi, controller_config())?;
69 Ok(Self {
70 controller,
71 interfaces,
72 })
73 }
74
75 /// Run a default scan and return up to [`ScanList`]'s capacity of results.
76 pub async fn scan(&mut self) -> Result<ScanList, WifiError> {
77 self.scan_with(&ScanConfig::default()).await
78 }
79
80 /// Run a scan with an explicit [`ScanConfig`].
81 pub async fn scan_with(&mut self, config: &ScanConfig) -> Result<ScanList, WifiError> {
82 Ok(to_scan_list(self.controller.scan_async(config).await?))
83 }
84}
85
86/// Build the `ControllerConfig`, trimming buffers per chip.
87///
88/// Defaults are tuned for CoreS3 (ESP32-S3). Fire27 (ESP32) trims the dynamic RX
89/// buffer count to save internal SRAM — it stays well above `rx_ba_win` (6) so
90/// the controller's validation is satisfied.
91fn controller_config() -> ControllerConfig {
92 let config = ControllerConfig::default();
93 #[cfg(feature = "fire27")]
94 let config = config.with_dynamic_rx_buf_num(16);
95 config
96}
97
98/// Truncate the controller's scan `Vec` into a fixed-capacity [`ScanList`].
99fn to_scan_list(aps: impl IntoIterator<Item = AccessPointInfo>) -> ScanList {
100 let mut out = ScanList::new();
101 for ap in aps.into_iter().take(out.capacity()) {
102 // capacity-bounded by take(): push cannot fail.
103 let _ = out.push(ap);
104 }
105 out
106}
107
108#[cfg(feature = "wifi-sta")]
109pub use sta::*;
110
111#[cfg(feature = "wifi-sta")]
112mod sta {
113 use super::*;
114 use embassy_futures::{
115 join::join,
116 select::{Either, select},
117 };
118 use embassy_sync::{
119 blocking_mutex::raw::CriticalSectionRawMutex, mutex::Mutex, signal::Signal,
120 };
121 use embassy_time::{Duration, Timer};
122 use esp_radio::wifi::{Config, Interface, PowerSaveMode, sta::StationConfig};
123
124 /// Station credentials for [`Wifi::into_sta`].
125 pub struct StaCredentials<'a> {
126 /// Network SSID.
127 pub ssid: &'a str,
128 /// Pre-shared key (empty for open networks).
129 pub password: &'a str,
130 /// Authentication method (`Wpa2Personal` is a sane default).
131 pub auth: AuthenticationMethod,
132 }
133
134 /// How the station obtains its IPv4 configuration.
135 pub enum IpSetup {
136 /// Acquire an address via DHCP.
137 Dhcp,
138 /// Use a fixed address/gateway/DNS.
139 Static(embassy_net::StaticConfigV4),
140 }
141
142 enum Command {
143 Scan(ScanConfig),
144 Connect,
145 Disconnect,
146 }
147
148 // Carried through a single static `Signal`, which reserves the max size
149 // regardless; boxing the scan result would only add an allocation.
150 #[allow(clippy::large_enum_variant)]
151 enum Response {
152 Scan(Result<ScanList, WifiError>),
153 Unit(Result<(), WifiError>),
154 }
155
156 // The controller is a single, non-shareable resource owned by `wifi_task`.
157 // `WifiControl` talks to it over these signals; `CTRL_LOCK` keeps at most one
158 // command outstanding so a reply is never clobbered before it is read.
159 static CMD: Signal<CriticalSectionRawMutex, Command> = Signal::new();
160 static RESP: Signal<CriticalSectionRawMutex, Response> = Signal::new();
161 static CTRL_LOCK: Mutex<CriticalSectionRawMutex, ()> = Mutex::new(());
162
163 /// Cloneable-free command handle for the running station.
164 ///
165 /// Association is managed automatically by [`wifi_task`]; use this to scan
166 /// while associated, or to explicitly stop/start association.
167 pub struct WifiControl {
168 _private: (),
169 }
170
171 /// The owned controller + network runner. Hand to [`wifi_task`].
172 pub struct WifiRunner {
173 controller: WifiController<'static>,
174 net: embassy_net::Runner<'static, Interface<'static>>,
175 desired_up: bool,
176 }
177
178 impl Wifi {
179 /// Configure station mode and build the `embassy_net` stack.
180 ///
181 /// `seed` should come from the application's TRNG (the BSP deliberately
182 /// does not claim `RNG`). `resources` is typically a
183 /// `static_cell`-allocated `StackResources<N>`. On Fire27 keep it (and
184 /// socket buffers) in internal RAM — that chip cannot DMA from PSRAM.
185 ///
186 /// Returns the `Stack` (await `stack.wait_config_up()` for an IP), the
187 /// [`WifiControl`] handle, and the [`WifiRunner`] to spawn.
188 pub fn into_sta<const N: usize>(
189 self,
190 creds: StaCredentials<'_>,
191 ip: IpSetup,
192 seed: u64,
193 resources: &'static mut embassy_net::StackResources<N>,
194 ) -> Result<(embassy_net::Stack<'static>, WifiControl, WifiRunner), WifiError> {
195 let Wifi {
196 mut controller,
197 interfaces,
198 } = self;
199
200 let station = StationConfig::default()
201 .with_ssid(creds.ssid)
202 .with_password(creds.password.into())
203 .with_auth_method(creds.auth);
204 controller.set_config(&Config::Station(station))?;
205 // Favor responsiveness over the blob's default power-save heuristics.
206 controller.set_power_saving(PowerSaveMode::None)?;
207
208 let net_config = match ip {
209 IpSetup::Dhcp => embassy_net::Config::dhcpv4(Default::default()),
210 IpSetup::Static(cfg) => embassy_net::Config::ipv4_static(cfg),
211 };
212 let (stack, net) = embassy_net::new(interfaces.station, net_config, resources, seed);
213
214 Ok((
215 stack,
216 WifiControl { _private: () },
217 WifiRunner {
218 controller,
219 net,
220 desired_up: true,
221 },
222 ))
223 }
224 }
225
226 impl WifiControl {
227 /// Scan with default settings while the station keeps running.
228 pub async fn scan(&self) -> Result<ScanList, WifiError> {
229 self.scan_with(ScanConfig::default()).await
230 }
231
232 /// Scan with an explicit [`ScanConfig`].
233 pub async fn scan_with(&self, config: ScanConfig) -> Result<ScanList, WifiError> {
234 let _guard = CTRL_LOCK.lock().await;
235 CMD.signal(Command::Scan(config));
236 match RESP.wait().await {
237 Response::Scan(result) => result,
238 Response::Unit(_) => unreachable!("scan command yields a scan response"),
239 }
240 }
241
242 /// Request (re)association to the configured AP.
243 pub async fn connect(&self) -> Result<(), WifiError> {
244 self.unit_command(Command::Connect).await
245 }
246
247 /// Disconnect and stop auto-reconnect until [`WifiControl::connect`].
248 pub async fn disconnect(&self) -> Result<(), WifiError> {
249 self.unit_command(Command::Disconnect).await
250 }
251
252 async fn unit_command(&self, command: Command) -> Result<(), WifiError> {
253 let _guard = CTRL_LOCK.lock().await;
254 CMD.signal(command);
255 match RESP.wait().await {
256 Response::Unit(result) => result,
257 Response::Scan(_) => unreachable!("unit command yields a unit response"),
258 }
259 }
260 }
261
262 /// Drive the network stack and manage station association.
263 ///
264 /// Joins the `embassy_net` runner with the controller actor; neither future
265 /// returns, so this task runs for the lifetime of the program.
266 #[embassy_executor::task]
267 pub async fn wifi_task(runner: WifiRunner) {
268 let WifiRunner {
269 mut controller,
270 mut net,
271 mut desired_up,
272 } = runner;
273 join(net.run(), run_actor(&mut controller, &mut desired_up)).await;
274 }
275
276 /// Single owner of the controller: auto-connects, reconnects on link loss,
277 /// and services [`WifiControl`] commands. Races association progress against
278 /// incoming commands so a scan/disconnect is handled promptly.
279 async fn run_actor(controller: &mut WifiController<'static>, desired_up: &mut bool) {
280 loop {
281 if *desired_up && !controller.is_connected() {
282 let outcome = {
283 let connect = controller.connect_async();
284 select(connect, CMD.wait()).await
285 };
286 match outcome {
287 Either::First(Ok(_)) => {} // connected — fall through to the wait branch
288 Either::First(Err(error)) => {
289 warn!("WiFi connect failed: {:?}", error);
290 // Back off, but still service commands during the wait.
291 let backoff = select(Timer::after(Duration::from_secs(2)), CMD.wait()).await;
292 if let Either::Second(command) = backoff {
293 handle(controller, desired_up, command).await;
294 }
295 }
296 Either::Second(command) => handle(controller, desired_up, command).await,
297 }
298 } else if *desired_up {
299 let outcome = {
300 let disconnect = controller.wait_for_disconnect_async();
301 select(disconnect, CMD.wait()).await
302 };
303 if let Either::Second(command) = outcome {
304 handle(controller, desired_up, command).await;
305 }
306 // Either::First => link lost; loop reconnects.
307 } else {
308 let command = CMD.wait().await;
309 handle(controller, desired_up, command).await;
310 }
311 }
312 }
313
314 async fn handle(
315 controller: &mut WifiController<'static>,
316 desired_up: &mut bool,
317 command: Command,
318 ) {
319 match command {
320 Command::Scan(config) => {
321 let result = controller
322 .scan_async(&config)
323 .await
324 .map(to_scan_list)
325 .map_err(WifiError::from);
326 RESP.signal(Response::Scan(result));
327 }
328 Command::Connect => {
329 *desired_up = true;
330 RESP.signal(Response::Unit(Ok(())));
331 }
332 Command::Disconnect => {
333 *desired_up = false;
334 let result = controller
335 .disconnect_async()
336 .await
337 .map(|_| ())
338 .map_err(WifiError::from);
339 RESP.signal(Response::Unit(result));
340 }
341 }
342 }
343}