Skip to main content

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}