Skip to main content

oxstack_cardputer/
net.rs

1//! Wi-Fi: scanning, and station networking (connect, DHCP, DNS, UDP/TCP via
2//! `embassy-net`) plus network time. Requires the `wifi` feature.
3//!
4//! ```ignore
5//! let stack = net::start_station(spawner, board.radio.wifi, Credentials { ssid, password })?;
6//! stack.wait_config_up().await;               // associated + DHCP lease
7//! let clock = net::sync_clock(stack, net::DEFAULT_NTP_SERVER).await?;
8//! let now = clock.now(utc_offset_minutes);    // calendar date/time
9//! ```
10//!
11//! [`start_station`] spawns two tasks: one keeps the link up (reconnecting
12//! every 5 s after a drop), the other runs the IP stack. Link state and
13//! signal strength are published in atomics ([`link`]), so any task can
14//! show them without owning the radio. It takes the radio for the rest of
15//! the boot.
16//!
17//! Scanning is separate and repeatable: [`scanner`] borrows the radio and
18//! releases it when the [`Scanner`] is dropped.
19//!
20//! ```ignore
21//! let mut scanner = net::scanner(board.radio.wifi.reborrow())?;
22//! for ap in scanner.scan().await? {
23//!     info!("{} dBm ch {} {}", ap.signal_strength, ap.channel, ap.ssid);
24//! }
25//! ```
26
27use core::sync::atomic::{AtomicI8, AtomicU8, Ordering};
28
29use embassy_executor::{SpawnError, Spawner};
30use embassy_futures::select::{Either, select};
31use embassy_net::dns::DnsQueryType;
32use embassy_net::udp::{PacketMetadata, UdpSocket};
33use embassy_net::{Runner, Stack, StackResources};
34use embassy_time::{Duration, Instant, Timer, with_timeout};
35use esp_hal::peripherals::WIFI;
36use esp_hal::time::Duration as HalDuration;
37pub use esp_radio::wifi::ap::AccessPointInfo;
38pub use esp_radio::wifi::scan::ScanConfig;
39use esp_radio::wifi::scan::ScanTypeConfig;
40use esp_radio::wifi::sta::StationConfig;
41pub use esp_radio::wifi::{AuthenticationMethod, WifiController};
42use esp_radio::wifi::{AuthenticationMethodConfig, Config, ControllerConfig, Interface, WifiError};
43use oxstack_net::sntp;
44pub use oxstack_net::sntp::ServerTime;
45pub use oxstack_net::time::DateTime;
46use static_cell::StaticCell;
47
48pub use embassy_net;
49
50/// A public NTP pool; any SNTP server name or IPv4 literal works.
51pub const DEFAULT_NTP_SERVER: &str = "pool.ntp.org";
52/// Sockets available to the application (DHCP and DNS use their own).
53pub const SOCKETS: usize = 4;
54
55/// Network credentials. An empty password selects an open network.
56#[derive(Clone, Copy, Debug)]
57pub struct Credentials<'a> {
58    /// Network name.
59    pub ssid: &'a str,
60    /// WPA2/WPA3-Personal passphrase, or empty for an open network.
61    pub password: &'a str,
62}
63
64/// Errors from [`start_station`] and [`scanner`].
65#[derive(Debug)]
66#[cfg_attr(feature = "defmt", derive(defmt::Format))]
67pub enum Error {
68    /// SSID longer than 32 bytes or passphrase longer than 64.
69    InvalidCredentials,
70    /// The radio driver failed.
71    Wifi(WifiError),
72    /// The executor had no room for the network tasks.
73    Spawn,
74}
75
76impl From<SpawnError> for Error {
77    fn from(_: SpawnError) -> Self {
78        Error::Spawn
79    }
80}
81
82/// Wi-Fi link status, readable from anywhere.
83pub mod link {
84    use super::*;
85
86    /// Connection state.
87    #[derive(Clone, Copy, Debug, PartialEq, Eq)]
88    #[cfg_attr(feature = "defmt", derive(defmt::Format))]
89    pub enum State {
90        /// Not started yet.
91        Idle = 0,
92        /// Associating with the access point.
93        Connecting = 1,
94        /// Associated (an IP address may still be pending; see
95        /// `Stack::config_v4`).
96        Connected = 2,
97        /// The last attempt failed; retrying.
98        Retrying = 3,
99    }
100
101    pub(super) static STATE: AtomicU8 = AtomicU8::new(State::Idle as u8);
102    pub(super) static RSSI: AtomicI8 = AtomicI8::new(i8::MIN);
103
104    /// Current connection state.
105    pub fn state() -> State {
106        match STATE.load(Ordering::Relaxed) {
107            1 => State::Connecting,
108            2 => State::Connected,
109            3 => State::Retrying,
110            _ => State::Idle,
111        }
112    }
113
114    /// Signal strength in dBm while connected, refreshed every 2 s.
115    pub fn rssi() -> Option<i8> {
116        let r = RSSI.load(Ordering::Relaxed);
117        (state() == State::Connected && r != i8::MIN).then_some(r)
118    }
119
120    pub(super) fn set(s: State) {
121        STATE.store(s as u8, Ordering::Relaxed);
122    }
123}
124
125/// Starts Wi-Fi in station mode with DHCP and returns the IP stack. Await
126/// `stack.wait_config_up()` before opening sockets.
127pub fn start_station(
128    spawner: Spawner,
129    wifi: WIFI<'static>,
130    creds: Credentials<'_>,
131) -> Result<Stack<'static>, Error> {
132    let ssid = creds
133        .ssid
134        .try_into()
135        .map_err(|_| Error::InvalidCredentials)?;
136    let auth = if creds.password.is_empty() {
137        AuthenticationMethodConfig::Open
138    } else {
139        // Acts as the minimum: WPA3-capable networks are still accepted.
140        AuthenticationMethodConfig::Wpa2Personal(
141            creds
142                .password
143                .try_into()
144                .map_err(|_| Error::InvalidCredentials)?,
145        )
146    };
147    let station = Config::Station(
148        StationConfig::default()
149            .with_ssid(ssid)
150            .with_authentication(auth),
151    );
152
153    let interface = Interface::station();
154    let controller = WifiController::new(
155        wifi,
156        ControllerConfig::default().with_initial_config(station),
157    )
158    .map_err(Error::Wifi)?;
159
160    static RESOURCES: StaticCell<StackResources<{ SOCKETS + 2 }>> = StaticCell::new();
161    let rng = esp_hal::rng::Rng::new();
162    // The hardware RNG draws entropy from the radio, which is now running.
163    let seed = u64::from(rng.random()) << 32 | u64::from(rng.random());
164    let (stack, runner) = embassy_net::new(
165        interface,
166        embassy_net::Config::dhcpv4(Default::default()),
167        RESOURCES.init(StackResources::new()),
168        seed,
169    );
170
171    spawner.spawn(link_task(controller)?);
172    spawner.spawn(net_task(runner)?);
173    Ok(stack)
174}
175
176#[embassy_executor::task]
177async fn link_task(mut controller: WifiController<'static>) {
178    loop {
179        link::set(link::State::Connecting);
180        if controller.connect_async().await.is_ok() {
181            link::set(link::State::Connected);
182            // Refresh RSSI until the link drops.
183            loop {
184                let rssi = controller
185                    .rssi()
186                    .ok()
187                    .map_or(i8::MIN, |r| r.clamp(-127, 0) as i8);
188                link::RSSI.store(rssi, Ordering::Relaxed);
189                let dropped = select(
190                    controller.wait_for_disconnect_async(),
191                    Timer::after(Duration::from_secs(2)),
192                )
193                .await;
194                if let Either::First(_) = dropped {
195                    break;
196                }
197            }
198        }
199        link::set(link::State::Retrying);
200        Timer::after(Duration::from_secs(5)).await;
201    }
202}
203
204#[embassy_executor::task]
205async fn net_task(mut runner: Runner<'static, Interface>) {
206    runner.run().await
207}
208
209/// Errors from [`query_time`].
210#[derive(Debug)]
211#[cfg_attr(feature = "defmt", derive(defmt::Format))]
212pub enum TimeError {
213    /// The server name didn't resolve.
214    Dns,
215    /// Socket error.
216    Socket,
217    /// No reply within 3 s.
218    Timeout,
219    /// The reply was invalid.
220    Sntp(sntp::Error),
221}
222
223/// Asks an SNTP server for the time (one UDP round trip, 3 s timeout).
224pub async fn query_time(stack: Stack<'static>, server: &str) -> Result<ServerTime, TimeError> {
225    let addrs = stack
226        .dns_query(server, DnsQueryType::A)
227        .await
228        .map_err(|_| TimeError::Dns)?;
229    let addr = *addrs.first().ok_or(TimeError::Dns)?;
230
231    let (mut rx_meta, mut tx_meta) = ([PacketMetadata::EMPTY; 2], [PacketMetadata::EMPTY; 2]);
232    let (mut rx_buf, mut tx_buf) = ([0u8; 128], [0u8; 128]);
233    let mut socket = UdpSocket::new(stack, &mut rx_meta, &mut rx_buf, &mut tx_meta, &mut tx_buf);
234    socket.bind(0).map_err(|_| TimeError::Socket)?;
235    socket
236        .send_to(&sntp::request(), (addr, sntp::PORT))
237        .await
238        .map_err(|_| TimeError::Socket)?;
239
240    let mut reply = [0u8; sntp::PACKET_LEN];
241    loop {
242        let (n, meta) = with_timeout(Duration::from_secs(3), socket.recv_from(&mut reply))
243            .await
244            .map_err(|_| TimeError::Timeout)?
245            .map_err(|_| TimeError::Socket)?;
246        // Ignore stray datagrams from anyone but the server we asked.
247        if meta.endpoint.addr == addr {
248            return sntp::parse_reply(&reply[..n]).map_err(TimeError::Sntp);
249        }
250    }
251}
252
253/// Wall-clock time anchored to the monotonic clock at one SNTP sample.
254#[derive(Clone, Copy, Debug)]
255#[cfg_attr(feature = "defmt", derive(defmt::Format))]
256pub struct WallClock {
257    /// Unix time in microseconds at monotonic instant zero (boot).
258    unix_us_at_boot: i64,
259}
260
261impl WallClock {
262    /// Anchors `t`, as received now.
263    pub fn from_server_time(t: ServerTime) -> Self {
264        let unix_us = t.unix_seconds as i64 * 1_000_000 + i64::from(t.micros);
265        Self {
266            unix_us_at_boot: unix_us - Instant::now().as_micros() as i64,
267        }
268    }
269
270    /// Microseconds since the Unix epoch.
271    pub fn unix_micros(&self) -> i64 {
272        self.unix_us_at_boot + Instant::now().as_micros() as i64
273    }
274
275    /// Seconds since the Unix epoch.
276    pub fn unix_seconds(&self) -> i64 {
277        self.unix_micros().div_euclid(1_000_000)
278    }
279
280    /// Microseconds into the current second, 0–999 999.
281    pub fn subsec_micros(&self) -> u32 {
282        self.unix_micros().rem_euclid(1_000_000) as u32
283    }
284
285    /// The monotonic instant at which [`unix_seconds`](Self::unix_seconds)
286    /// next changes, so a clock face can redraw exactly on the second:
287    /// `Timer::at(clock.next_second()).await`.
288    pub fn next_second(&self) -> Instant {
289        let to_go = 1_000_000 - u64::from(self.subsec_micros());
290        Instant::now() + Duration::from_micros(to_go)
291    }
292
293    /// Calendar date and time at `offset_minutes` east of UTC.
294    pub fn now(&self, offset_minutes: i32) -> DateTime {
295        DateTime::from_unix(self.unix_seconds(), offset_minutes)
296    }
297}
298
299/// [`query_time`] and anchor the result.
300pub async fn sync_clock(stack: Stack<'static>, server: &str) -> Result<WallClock, TimeError> {
301    Ok(WallClock::from_server_time(
302        query_time(stack, server).await?,
303    ))
304}
305
306/// A scan configuration that finds networks reliably: active probing with
307/// 30–120 ms per channel (ESP-IDF's maximum; `esp-radio`'s 10–20 ms default
308/// misses access points whose beacons come every ~100 ms), hidden networks
309/// included, at most 32 results.
310pub fn scan_config() -> ScanConfig {
311    ScanConfig::default()
312        .with_max(32)
313        .with_show_hidden(true)
314        .with_scan_type(ScanTypeConfig::Active {
315            min: HalDuration::from_millis(30),
316            max: HalDuration::from_millis(120),
317        })
318}
319
320/// A Wi-Fi scanner. Dropping it switches the radio off again.
321pub struct Scanner<'d> {
322    controller: WifiController<'d>,
323}
324
325/// Starts the radio for scanning. Takes about 1.5 s per [`Scanner::scan`].
326pub fn scanner(wifi: WIFI<'_>) -> Result<Scanner<'_>, Error> {
327    let controller = WifiController::new(wifi, ControllerConfig::default()).map_err(Error::Wifi)?;
328    Ok(Scanner { controller })
329}
330
331impl<'d> Scanner<'d> {
332    /// Scans with [`scan_config`]; results come strongest first.
333    pub async fn scan(&mut self) -> Result<alloc::vec::Vec<AccessPointInfo>, Error> {
334        self.scan_with(&scan_config()).await
335    }
336
337    /// Scans with a custom configuration; results come strongest first.
338    pub async fn scan_with(
339        &mut self,
340        config: &ScanConfig,
341    ) -> Result<alloc::vec::Vec<AccessPointInfo>, Error> {
342        let mut aps = self
343            .controller
344            .scan_async(config)
345            .await
346            .map_err(Error::Wifi)?;
347        aps.sort_unstable_by_key(|ap| core::cmp::Reverse(ap.signal_strength));
348        Ok(aps)
349    }
350
351    /// The underlying controller.
352    pub fn controller(&mut self) -> &mut WifiController<'d> {
353        &mut self.controller
354    }
355}