Skip to main content

dmx_interface/net/
mod.rs

1//! `WiFi` radio and TCP/IP stack (`esp-radio` + `embassy-net`).
2//!
3//! One of two modes is chosen at boot from [`Config::connection`]:
4//!
5//! - **Access point** (default): static IPv4 `192.168.4.1/24` plus a DHCP
6//!   server handing out leases to AP clients ([`dhcp`]).
7//! - **Station**: joins the configured network and gets its address via
8//!   DHCP. If the association does not come up within [`STA_CONNECT_TIMEOUT`]
9//!   — or the DHCP lease does not arrive within [`STA_DHCP_TIMEOUT`] — the
10//!   device switches to AP mode instead so it stays reachable. After a
11//!   successful station boot, a lost association is retried every
12//!   [`STA_RECONNECT_DELAY`] (the device then stays in station mode, so
13//!   reachability depends on the AP coming back).
14//!
15//! The event loop at the end of [`task`] doubles as the controller
16//! keep-alive: dropping `WifiController` de-initializes the radio.
17//!
18//! In both modes the HTTP/WebSocket server ([`http`], [`ws`]) is started
19//! on the stack once the interface is up.
20
21use embassy_executor::Spawner;
22use embassy_futures::select::{Either, select};
23use embassy_net::{
24    Config as NetConfig, Ipv4Address, Ipv4Cidr, Runner, StackResources, StaticConfigV4,
25};
26use embassy_time::{Duration, Instant, Timer};
27use esp_hal::peripherals::WIFI;
28use esp_radio::wifi::{
29    AuthenticationMethod, Config as WifiConfig, ControllerConfig, Interface, WifiController,
30    ap::AccessPointConfig,
31    event::{EventInfo, MessageResult},
32    sta::StationConfig,
33};
34use heapless::String;
35use static_cell::StaticCell;
36
37use crate::config::{Config, ConnectionType};
38use crate::hardware::efuse;
39
40mod dhcp;
41pub mod http;
42mod ws;
43
44/// Static IPv4 address of the access point — also the DHCP server
45/// identifier handed out in option 54.
46pub const AP_IP: Ipv4Address = Ipv4Address::new(192, 168, 4, 1);
47
48/// Prefix length of the access-point subnet.
49pub const AP_PREFIX: u8 = 24;
50
51/// Maximum time to wait for a station association before falling back to
52/// access-point mode.
53const STA_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
54
55/// Maximum time to wait for the station DHCP lease before falling back to
56/// access-point mode.
57const STA_DHCP_TIMEOUT: Duration = Duration::from_secs(15);
58
59/// Pause between station re-association attempts after a runtime disconnect
60/// (the boot-time fallback does not apply here — the device keeps retrying).
61const STA_RECONNECT_DELAY: Duration = Duration::from_secs(5);
62
63/// Socket slots in the network stack — four HTTP connection slots plus
64/// the DHCP server (AP mode) or DHCP client (station mode), with headroom.
65const SOCKETS: usize = 8;
66
67/// Socket storage for the station-mode stack — used when the boot path
68/// attempts a station lease (the cell is abandoned if DHCP times out).
69static STACK_STA: StaticCell<StackResources<SOCKETS>> = StaticCell::new();
70
71/// Socket storage for the AP-mode stack, kept separate from
72/// [`STACK_STA`] so a DHCP-timeout fallback can bring the AP up beside
73/// the idle station stack (both cells always cost ~2 KiB of `.bss`).
74static STACK_AP: StaticCell<StackResources<SOCKETS>> = StaticCell::new();
75
76/// Resolves the AP SSID: the configured one, or a MAC-derived default.
77fn ap_ssid(config: &Config) -> String<32> {
78    if config.ap_config.ssid.is_empty() {
79        efuse::default_ap_ssid()
80    } else {
81        config.ap_config.ssid.clone()
82    }
83}
84
85/// Builds the access-point configuration for `esp-radio`.
86///
87/// `password` is only applied when it meets the WPA2 minimum length of 8;
88/// an empty or shorter password yields an open network.
89fn build_ap(ssid: &str, password: &str, wpa2: bool) -> AccessPointConfig {
90    let ap = AccessPointConfig::default().with_ssid(ssid);
91    if wpa2 {
92        ap.with_auth_method(AuthenticationMethod::Wpa2Personal)
93            .with_password(password.into())
94    } else {
95        ap
96    }
97}
98
99/// Builds the station configuration for `esp-radio`.
100///
101/// Uses the same WPA2 rule as [`build_ap`]: a password of at least 8
102/// characters selects WPA2 personal, anything else joins openly.
103fn build_sta(config: &Config) -> StationConfig {
104    let password = config.station_config.password.as_str();
105    let sta = StationConfig::default().with_ssid(config.station_config.ssid.as_str());
106    if password.len() >= 8 {
107        sta.with_auth_method(AuthenticationMethod::Wpa2Personal)
108            .with_password(password.into())
109    } else {
110        if !password.is_empty() {
111            log::warn!("WiFi station password below the WPA2 minimum of 8 chars — joining openly");
112        }
113        sta.with_auth_method(AuthenticationMethod::None)
114    }
115}
116
117/// Deterministic seed for the network stack, derived from the factory
118/// MAC address (keeps ephemeral port selection stable across reboots).
119fn stack_seed() -> u64 {
120    let [b0, b1, b2, b3, b4, b5] = efuse::mac_bytes();
121    u64::from_le_bytes([b0, b1, b2, b3, b4, b5, 0, 0])
122}
123
124/// Races the station association against [`STA_CONNECT_TIMEOUT`].
125///
126/// Returns `true` only for a connected result; a disconnect, driver error
127/// or timeout all yield `false` so the caller can fall back to AP mode.
128async fn try_station(controller: &mut WifiController<'_>) -> bool {
129    match select(
130        controller.connect_async(),
131        Timer::after(STA_CONNECT_TIMEOUT),
132    )
133    .await
134    {
135        Either::First(Ok(info)) => {
136            log::info!(
137                "WiFi station '{}' connected (channel {})",
138                info.ssid.as_str(),
139                info.channel
140            );
141            true
142        }
143        Either::First(Err(err)) => {
144            log::warn!("WiFi station connect failed: {err:?}");
145            false
146        }
147        Either::Second(()) => {
148            log::warn!("WiFi station connect timed out");
149            false
150        }
151    }
152}
153
154/// Brings up the embassy-net stack on the station interface with a DHCP
155/// client and waits for the lease.
156///
157/// Returns `false` when no lease arrives within [`STA_DHCP_TIMEOUT`] so
158/// the caller can fall back to AP mode. The station stack is then
159/// abandoned: its runner idles safely (the driver refuses TX while the
160/// link is down) and never spawned HTTP sockets.
161async fn start_station(device: Interface<'static>, spawner: Spawner) -> bool {
162    #[allow(clippy::default_trait_access)]
163    let net_config = NetConfig::dhcpv4(Default::default());
164    let (stack, runner) = embassy_net::new(
165        device,
166        net_config,
167        STACK_STA.init(StackResources::new()),
168        stack_seed(),
169    );
170    spawn_runner(runner, spawner);
171    log::info!("WiFi station: waiting for a DHCP lease");
172    let deadline = Instant::now().saturating_add(STA_DHCP_TIMEOUT);
173    let warn_at = Instant::now().saturating_add(Duration::from_secs(10));
174    let mut lease_warned = false;
175    loop {
176        if let Some(cfg) = stack.config_v4() {
177            log::info!("WiFi station up: ip {}", cfg.address.address());
178            break;
179        }
180        if Instant::now() >= deadline {
181            log::warn!(
182                "WiFi station: no DHCP lease after {} s — falling back to AP",
183                STA_DHCP_TIMEOUT.as_secs()
184            );
185            return false;
186        }
187        if !lease_warned && Instant::now() >= warn_at {
188            log::warn!("WiFi station: no DHCP lease yet — still waiting");
189            lease_warned = true;
190        }
191        Timer::after(Duration::from_millis(250)).await;
192    }
193
194    http::spawn_all(spawner, stack);
195    log::info!("http/ws server up on port {}", http::HTTP_PORT);
196    true
197}
198
199/// Brings up the embassy-net stack on the access-point interface with a
200/// static address and spawns the DHCP server for AP clients.
201async fn start_ap(device: Interface<'static>, ssid: &str, spawner: Spawner) {
202    #[allow(clippy::default_trait_access)]
203    let net_config = NetConfig::ipv4_static(StaticConfigV4 {
204        address: Ipv4Cidr::new(AP_IP, AP_PREFIX),
205        gateway: None,
206        dns_servers: Default::default(),
207    });
208    let (stack, runner) = embassy_net::new(
209        device,
210        net_config,
211        STACK_AP.init(StackResources::new()),
212        stack_seed(),
213    );
214    spawn_runner(runner, spawner);
215    stack.wait_link_up().await;
216    log::info!("WiFi AP up: {AP_IP} (ssid '{ssid}')");
217
218    match dhcp::task(stack) {
219        Ok(token) => spawner.spawn(token),
220        Err(err) => log::error!("DHCP server spawn failed: {err:?}"),
221    }
222
223    http::spawn_all(spawner, stack);
224    log::info!("http/ws server up on port {}", http::HTTP_PORT);
225}
226
227/// Spawns the embassy-net driver loop (logged, non-fatal on failure).
228fn spawn_runner(runner: Runner<'static, Interface<'static>>, spawner: Spawner) {
229    match runner_task(runner) {
230        Ok(token) => spawner.spawn(token),
231        Err(err) => log::error!("network runner spawn failed: {err:?}"),
232    }
233}
234
235/// Network task: brings up `WiFi` in station or AP mode and runs the
236/// event loop.
237///
238/// Spawned from `main` after logging and the embassy scheduler are up.
239/// Never returns — besides logging driver events (client join/leave,
240/// station disconnects), the final loop holds the controller alive; in
241/// station mode it also re-associates after [`EventInfo::StationDisconnected`].
242#[embassy_executor::task]
243pub async fn task(wifi: WIFI<'static>, config: Config, spawner: Spawner) {
244    let ssid = ap_ssid(&config);
245    let password = config.ap_config.password.as_str();
246    let wpa2 = password.len() >= 8;
247    if !password.is_empty() && !wpa2 {
248        log::warn!("WiFi AP password below the WPA2 minimum of 8 chars — open network");
249    }
250
251    // Station mode is only attempted with a configured SSID; an empty SSID
252    // degrades to the AP so the device never ends up with no radio at all.
253    let want_sta =
254        config.connection == ConnectionType::WifiSta && !config.station_config.ssid.is_empty();
255    if config.connection == ConnectionType::WifiSta && config.station_config.ssid.is_empty() {
256        log::warn!("WiFi station mode selected but no SSID configured — starting AP");
257    }
258
259    let initial_config = if want_sta {
260        log::info!(
261            "WiFi: connecting to station '{}'",
262            config.station_config.ssid
263        );
264        WifiConfig::Station(build_sta(&config))
265    } else {
266        log::info!(
267            "WiFi: starting AP '{ssid}' ({})",
268            if wpa2 { "WPA2" } else { "open" }
269        );
270        WifiConfig::AccessPoint(build_ap(ssid.as_str(), password, wpa2))
271    };
272
273    let init = esp_radio::wifi::new(
274        wifi,
275        ControllerConfig::default().with_initial_config(initial_config),
276    );
277    if let Err(err) = &init {
278        log::error!("WiFi init failed: {err:?} — network stack disabled");
279    }
280    let Ok((mut controller, interfaces)) = init else {
281        loop {
282            core::future::pending::<()>().await;
283        }
284    };
285
286    // The chain stops at the first failure — association timeout, DHCP
287    // lease timeout or a non-station boot all land in the AP fallback.
288    let station_up = want_sta
289        && try_station(&mut controller).await
290        && start_station(interfaces.station, spawner).await;
291    if !station_up {
292        if want_sta {
293            log::warn!("WiFi station unavailable — falling back to AP '{ssid}'");
294            let fallback = controller.set_config(&WifiConfig::AccessPoint(build_ap(
295                ssid.as_str(),
296                password,
297                wpa2,
298            )));
299            if let Err(err) = fallback {
300                log::error!("WiFi AP fallback failed: {err:?} — network stack disabled");
301                loop {
302                    core::future::pending::<()>().await;
303                }
304            }
305        }
306        start_ap(interfaces.access_point, ssid.as_str(), spawner).await;
307    }
308
309    // Subscribe after the (mutable) setup above; the subscriber borrow also
310    // keeps the controller alive for the loop below. Station mode watches
311    // for `StationDisconnected` and re-associates (esp-radio does not
312    // reconnect on its own — `connect_async` only covers the first attempt).
313    let mut subscriber = match controller.subscribe() {
314        Ok(subscriber) => Some(subscriber),
315        Err(err) => {
316            log::warn!("WiFi event subscription failed: {err:?}");
317            None
318        }
319    };
320    loop {
321        let link_lost = match subscriber.as_mut() {
322            Some(events) => match events.next_event().await {
323                MessageResult::Message(info) => {
324                    log::info!("WiFi event: {info:?}");
325                    station_up && matches!(info, EventInfo::StationDisconnected { .. })
326                }
327                MessageResult::Lagged(missed) => {
328                    log::warn!("WiFi event queue lagged, {missed} dropped");
329                    false
330                }
331            },
332            None => core::future::pending::<bool>().await,
333        };
334        if link_lost {
335            log::warn!("WiFi station link lost — re-associating");
336            drop(subscriber); // release the &self borrow before &mut controller
337            while !try_station(&mut controller).await {
338                Timer::after(STA_RECONNECT_DELAY).await;
339            }
340            subscriber = match controller.subscribe() {
341                Ok(subscriber) => Some(subscriber),
342                Err(err) => {
343                    log::error!("WiFi event subscription failed after reconnect: {err:?}");
344                    None
345                }
346            };
347        }
348    }
349}
350
351/// Drives the network device event loop (spawned by [`spawn_runner`]).
352#[embassy_executor::task]
353async fn runner_task(mut runner: Runner<'static, Interface<'static>>) {
354    runner.run().await;
355}