esp_csi_rs_core/node.rs
1//! Node topology/configuration types and the [`CSINode`] orchestrator.
2//!
3//! This module owns the user-facing description of a CSI node — its role
4//! ([`Node`] / [`CentralOpMode`] / [`PeripheralOpMode`]), the per-mode configs
5//! ([`EspNowConfig`], [`WifiSnifferConfig`], [`WifiStationConfig`]), the
6//! collection and TX/RX toggles — and [`CSINode`], whose `run` / `run_duration`
7//! wire up Wi-Fi, CSI, and the mode-specific tasks. It also holds the shared
8//! stop signal and the per-run lifecycle helpers.
9
10#[cfg(any(feature = "async-print", feature = "auto"))]
11use embassy_time::with_timeout;
12
13use embassy_futures::join::{join, join3};
14use embassy_futures::select::{Either, select};
15use embassy_time::{Duration, Timer};
16use enumset::EnumSet;
17use esp_radio::esp_now::WifiPhyRate;
18#[cfg(feature = "esp32c5")]
19use esp_radio::wifi::BandMode;
20use esp_radio::wifi::sta::StationConfig;
21use esp_radio::wifi::{Interfaces, Protocol, Protocols, SecondaryChannel, WifiController};
22
23use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
24use embassy_sync::signal::Signal;
25use portable_atomic::Ordering;
26
27use crate::central::ap::{ap_init, run_ap};
28use crate::central::esp_now::run_esp_now_central;
29use crate::central::esp_now_fast::run_esp_now_fast_collector;
30use crate::central::sta::{run_sta_connect, sta_init};
31use crate::config::CsiConfig as CsiConfiguration;
32use crate::peripheral::esp_now::run_esp_now_peripheral;
33use crate::peripheral::esp_now_fast::run_esp_now_fast_source;
34use crate::profile::{RadioProfile, StandardProfile};
35
36use crate::csi::delivery::{
37 CSINodeClient, IS_COLLECTOR, build_csi_config, run_process_csi_packet, set_csi,
38};
39use crate::espnow_phy::bring_up_espnow_sta;
40use crate::espnow_phy::{
41 apply_espnow_ht40_mode, install_static_espnow_recv, takeover_esp_now_recv,
42 with_espnow_recv_suspended,
43};
44#[cfg(feature = "esp32c5")]
45use crate::espnow_phy::apply_espnow_band_for_channel;
46use crate::log_ln;
47use crate::stats::set_seq_drop_detection;
48
49// Signals
50pub(crate) static STOP_SIGNAL: Signal<CriticalSectionRawMutex, ()> = Signal::new();
51
52/// Per-mutation radio-quiesce delay on C5 dual-band bring-up.
53///
54/// The C5 Wi-Fi ISR can wedge if a MAC interrupt fires mid-reconfiguration
55/// (`set_protocols` / `set_config` STA restart / `set_csi` / `set_channel`),
56/// tripping the interrupt watchdog (`handle_interrupts` backtrace at boot) or
57/// hard-freezing before any task runs. `with_espnow_recv_suspended` already
58/// shrinks that window; inserting a short settle *between* the mutations lets
59/// the MAC drain any pending interrupt before the next driver call, shrinking it
60/// further. This is a probabilistic mitigation, not a guarantee — the radio
61/// restart still races the MAC IRQ — so keeping no ESP-NOW traffic on air during
62/// a node's bring-up remains the most effective measure.
63#[cfg(feature = "esp32c5")]
64const C5_RADIO_SETTLE_MS: u64 = 60;
65
66/// Await a brief radio-settle delay on C5; no-op on every other chip.
67/// See [`C5_RADIO_SETTLE_MS`].
68async fn c5_radio_settle() {
69 #[cfg(feature = "esp32c5")]
70 Timer::after(Duration::from_millis(C5_RADIO_SETTLE_MS)).await;
71}
72
73async fn csi_data_collection(client: &mut CSINodeClient, duration: u64) {
74 #[cfg(any(feature = "async-print", feature = "auto"))]
75 if crate::logging::logging::is_async_logging_active() {
76 with_timeout(Duration::from_secs(duration), async {
77 loop {
78 client.print_csi_w_metadata().await;
79 }
80 })
81 .await
82 .unwrap_err();
83 client.send_stop().await;
84 return;
85 }
86
87 #[cfg(not(any(feature = "async-print", feature = "auto")))]
88 {
89 let _ = client;
90 }
91 Timer::after(Duration::from_secs(duration)).await;
92 client.send_stop().await;
93}
94
95async fn wait_for_stop() {
96 STOP_SIGNAL.wait().await;
97 STOP_SIGNAL.signal(());
98}
99
100async fn stop_after_duration(duration: u64) {
101 match select(
102 STOP_SIGNAL.wait(),
103 Timer::after(Duration::from_secs(duration)),
104 )
105 .await
106 {
107 Either::First(_) | Either::Second(_) => STOP_SIGNAL.signal(()),
108 }
109}
110
111/// Configuration for ESP-NOW traffic generation.
112///
113/// Used by both Central and Peripheral nodes when operating in ESP-NOW mode.
114/// Construct with `EspNowConfig::default()` then chain `with_channel` /
115/// `with_phy_rate` to override defaults — both nodes must agree on the
116/// channel for ESP-NOW frames to be received.
117pub struct EspNowConfig {
118 phy_rate: WifiPhyRate,
119 pub(crate) channel: u8,
120 /// Optional pre-configured peer MAC. When `None` (default) the pair uses
121 /// automatic, magic-prefix-based pairing. When `Some`, the magic prefix is
122 /// dropped from every frame and the source-MAC filter is the discriminator
123 /// from the first frame — both nodes must each be configured with the
124 /// other's MAC.
125 peer_mac: Option<[u8; 6]>,
126 /// Optional HT40 secondary channel. When `Some`, the node runs HT40 (40 MHz)
127 /// on `channel` + this secondary; when `None`, HT20. Only meaningful when
128 /// `force_phy` is set.
129 secondary_channel: Option<SecondaryChannel>,
130 /// When set, the node forces the ESP-NOW TX PHY (`phy_rate` +
131 /// HT20/HT40 from `secondary_channel`) via a per-peer rate config — which
132 /// requires bringing the radio up in started STA mode. When clear (default),
133 /// the radio is left in its default state and ESP-NOW frames go out at the
134 /// driver's default (legacy) PHY. Set by `with_phy_rate` / `with_ht40`.
135 force_phy: bool,
136}
137
138impl Default for EspNowConfig {
139 fn default() -> Self {
140 Self {
141 phy_rate: WifiPhyRate::RateMcs0Lgi,
142 // Channel 1 is empirically less congested than 11 in most
143 // residential / office environments — APs on auto-select tend
144 // to bias toward 11 because it's the upper bound in US/EU.
145 // Override with `with_channel` if your environment differs.
146 channel: 1,
147 peer_mac: None,
148 secondary_channel: None,
149 force_phy: false,
150 }
151 }
152}
153
154impl EspNowConfig {
155 /// Recommended base config for the fast one-to-one (asymmetric simplex)
156 /// mode: forces HT20 at MCS7 Long-GI for maximum CSI packets/sec. Chain
157 /// `with_channel` / `with_ht40` to override. Used by
158 /// [`CentralOpMode::EspNowFastCollector`] / [`PeripheralOpMode::EspNowFastSource`].
159 pub fn fast_default() -> Self {
160 Self::default().with_phy_rate(WifiPhyRate::RateMcs7Lgi)
161 }
162
163 /// Override the 2.4 GHz channel (1–14). Both central and peripheral
164 /// must be configured with the same channel.
165 pub fn with_channel(mut self, channel: u8) -> Self {
166 self.channel = channel;
167 self
168 }
169
170 /// Force the ESP-NOW TX PHY rate (e.g. `RateMcs0Lgi` … `RateMcs7Lgi`, or a
171 /// legacy rate). Applied per-peer via `esp_now_set_peer_rate_config`, which
172 /// brings the radio up in started STA mode. Combine with [`with_ht40`] for
173 /// a 40 MHz bandwidth; without it the rate is sent at HT20 (for MCS rates)
174 /// or the matching legacy mode. Without calling this (or `with_ht40`) the
175 /// PHY is left at the driver default.
176 ///
177 /// [`with_ht40`]: EspNowConfig::with_ht40
178 pub fn with_phy_rate(mut self, phy_rate: WifiPhyRate) -> Self {
179 self.phy_rate = phy_rate;
180 self.force_phy = true;
181 self
182 }
183
184 /// Pre-configure the peer's MAC address for manual pairing.
185 ///
186 /// Switches off automatic magic-prefix pairing: no magic is sent, and each
187 /// node accepts frames only from the configured peer MAC (source-MAC
188 /// filtering applies from the first frame). The central must be given the
189 /// peripheral's MAC and vice-versa, and both nodes must use the same
190 /// pairing mode for frames to parse.
191 pub fn with_peer_mac(mut self, peer_mac: [u8; 6]) -> Self {
192 self.peer_mac = Some(peer_mac);
193 self
194 }
195
196 /// Configured 2.4 GHz channel.
197 pub fn channel(&self) -> u8 {
198 self.channel
199 }
200
201 /// Configured PHY rate.
202 pub fn phy_rate(&self) -> &WifiPhyRate {
203 &self.phy_rate
204 }
205
206 /// Configured peer MAC for manual pairing, or `None` for automatic
207 /// magic-prefix pairing.
208 pub fn peer_mac(&self) -> Option<[u8; 6]> {
209 self.peer_mac
210 }
211
212 /// Run the ESP-NOW TX at HT40 (40 MHz) with `secondary` as the HT40
213 /// secondary channel, using the configured [`with_phy_rate`] (default
214 /// `RateMcs0Lgi`). Implies `force_phy`. Without this the PHY is HT20 (if a
215 /// rate is forced) or the driver default. Verify on-air (CSI `bandwidth`
216 /// field) that HT40 actually engaged.
217 ///
218 /// [`with_phy_rate`]: EspNowConfig::with_phy_rate
219 pub fn with_ht40(mut self, secondary: SecondaryChannel) -> Self {
220 self.secondary_channel = Some(secondary);
221 self.force_phy = true;
222 self
223 }
224
225 /// Configured HT40 secondary channel, or `None` for HT20.
226 pub fn secondary_channel(&self) -> Option<SecondaryChannel> {
227 self.secondary_channel
228 }
229
230 /// Whether the ESP-NOW TX PHY (rate + bandwidth) is forced via a per-peer
231 /// rate config (set by [`with_phy_rate`] / [`with_ht40`]).
232 ///
233 /// [`with_phy_rate`]: EspNowConfig::with_phy_rate
234 /// [`with_ht40`]: EspNowConfig::with_ht40
235 pub fn force_phy(&self) -> bool {
236 self.force_phy
237 }
238}
239
240/// Configuration for Wi-Fi Promiscuous Sniffer mode.
241///
242/// Construct with `WifiSnifferConfig::default()` then chain `with_channel`
243/// to override defaults.
244#[derive(Debug, Clone)]
245pub struct WifiSnifferConfig {
246 /// Optional MAC source filter (reserved — not yet wired into the
247 /// promiscuous filter setup).
248 #[allow(dead_code)]
249 mac_filter: Option<[u8; 6]>,
250 channel: u8,
251}
252
253impl Default for WifiSnifferConfig {
254 fn default() -> Self {
255 Self {
256 mac_filter: None,
257 // Match `EspNowConfig` default — channel 1 is typically less
258 // congested than 11 in dense residential / office environments.
259 channel: 1,
260 }
261 }
262}
263
264impl WifiSnifferConfig {
265 /// Override the channel the sniffer locks to.
266 ///
267 /// Must be a valid IEEE 802.11 **primary** channel number — pass the
268 /// primary, not the wider-channel center notation that routers
269 /// commonly display:
270 ///
271 /// - **2.4 GHz**: `1`–`14`
272 /// - **5 GHz**: `36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112,
273 /// 116, 120, 124, 128, 132, 136, 140, 144, 149, 153, 157, 161, 165`
274 /// (regulatory-domain dependent — some restricted by `country_info`)
275 ///
276 /// Center-channel labels (`38, 46, ...` for HT40; `42, 58, 106, ...`
277 /// for VHT80; `50, 114` for VHT160; `154` for the 153/157 HT40 pair)
278 /// are **not** accepted here — `esp_wifi_set_channel` panics with
279 /// `InvalidArguments`. For example, a router showing "channel 154"
280 /// is using primary `153` (or `157`); pass that primary and the chip
281 /// will sniff the full 40 MHz block automatically per 802.11.
282 ///
283 /// On dual-band chips (currently ESP32-C5), the band is auto-selected
284 /// from the channel number — channels `>= 36` switch the radio to
285 /// `BandMode::_5G`, otherwise `BandMode::_2_4G`. On 2.4-GHz-only
286 /// chips, passing any 5 GHz channel will fail at runtime.
287 pub fn with_channel(mut self, channel: u8) -> Self {
288 self.channel = channel;
289 self
290 }
291
292 /// Configured channel (2.4 GHz: 1–14, 5 GHz: 36–165).
293 pub fn channel(&self) -> u8 {
294 self.channel
295 }
296}
297
298/// Configuration for Wi-Fi Station mode.
299#[derive(Debug, Clone)]
300pub struct WifiStationConfig {
301 /// Underlying esp-radio station configuration (SSID, auth, etc.).
302 pub client_config: StationConfig,
303 /// Primary channel of the target AP. On dual-band ESP32-C5 this selects
304 /// 2.4 vs 5 GHz (`set_band_mode`) before scan/association.
305 pub channel_hint: Option<u8>,
306}
307
308impl WifiStationConfig {
309 /// Build a station config from esp-radio's [`StationConfig`].
310 pub fn new(client_config: StationConfig) -> Self {
311 Self {
312 client_config,
313 channel_hint: None,
314 }
315 }
316
317 /// Pin the radio band from the AP's primary channel (C5 dual-band only).
318 pub fn with_channel_hint(mut self, channel: u8) -> Self {
319 self.channel_hint = Some(channel);
320 self
321 }
322}
323
324#[cfg(feature = "defmt")]
325impl defmt::Format for WifiStationConfig {
326 fn format(&self, fmt: defmt::Formatter<'_>) {
327 defmt::write!(fmt, "WifiStationConfig {{ client_config: <opaque> }}");
328 }
329}
330
331/// Configuration for self-contained softAP CSI collector mode.
332///
333/// Wraps esp-radio's [`AccessPointConfig`] (SSID, channel, auth, secondary
334/// channel) and the static IPv4 addressing used by the built-in DHCP server.
335/// The AP hands associating stations addresses from a lease pool in the AP's /24
336/// subnet with the gateway set to the AP itself.
337///
338/// `channel`/`secondary_channel` are duplicated here because esp-radio's
339/// `AccessPointConfig` fields are not externally readable; [`CSINode`] needs them
340/// for band/HT40 setup.
341///
342/// [`AccessPointConfig`]: esp_radio::wifi::ap::AccessPointConfig
343pub struct WifiApConfig {
344 /// Underlying esp-radio access-point configuration.
345 pub ap_config: esp_radio::wifi::ap::AccessPointConfig,
346 /// Primary channel the AP operates on (mirror of `ap_config`'s channel).
347 pub channel: u8,
348 /// Optional HT40 secondary channel (mirror of `ap_config`'s secondary).
349 pub secondary_channel: Option<SecondaryChannel>,
350 /// AP's static IPv4 address; also the gateway and DHCP server identifier.
351 pub ap_ipv4: core::net::Ipv4Addr,
352 /// First IPv4 address in the DHCP lease pool (typically `.2`).
353 pub lease_ipv4: core::net::Ipv4Addr,
354 /// Number of consecutive lease addresses starting at [`Self::lease_ipv4`]
355 /// (e.g. `3` → `.2`, `.3`, `.4`). Default `1` preserves the original
356 /// single-client behaviour.
357 pub lease_count: u8,
358 /// Whether to run the built-in DHCP server. When `false`, the AP only starts
359 /// + collects CSI (clients must self-assign IPs).
360 pub serve_dhcp: bool,
361 /// When `true`, every flood tick fires one unicast frame back-to-back to
362 /// **all** active leases instead of advancing one lease per tick (round-robin).
363 /// All associated stations then receive their downlink PPDU within tens of
364 /// microseconds of each other — temporally-synchronized multi-receiver CSI —
365 /// instead of being spread across the whole tick interval.
366 ///
367 /// This is the workable path to synchronized multi-receiver CSI. A single
368 /// group-addressed broadcast frame does *not* work on an ESP32 softAP:
369 /// broadcast/multicast is DTIM-buffered, dropped under a high-rate flood, and
370 /// only ever sent at the legacy basic rate — so it mostly never leaves the
371 /// radio and never honours a forced high-throughput TX rate. Only unicast
372 /// transmits immediately and honours the configured TX rate, so N unicast
373 /// frames per tick keep near-simultaneous arrival across receivers. Stations
374 /// must be **associated** — an unassociated receiver does not reliably
375 /// produce CSI from overheard frames.
376 ///
377 /// Per-receiver rate is the configured ping rate; total offered rate is
378 /// `rate * lease_count`, so lower the rate if airtime saturates. Default
379 /// `false` preserves per-lease round-robin. Set by [`Self::with_sync_burst`].
380 pub sync_burst: bool,
381}
382
383impl WifiApConfig {
384 /// Create a config from an [`AccessPointConfig`], its primary `channel`, and
385 /// optional HT40 `secondary` channel. Defaults the AP to `192.168.13.1/24`,
386 /// leases `192.168.13.2`, and enables the DHCP server.
387 ///
388 /// [`AccessPointConfig`]: esp_radio::wifi::ap::AccessPointConfig
389 pub fn new(
390 ap_config: esp_radio::wifi::ap::AccessPointConfig,
391 channel: u8,
392 secondary: Option<SecondaryChannel>,
393 ) -> Self {
394 Self {
395 ap_config,
396 channel,
397 secondary_channel: secondary,
398 ap_ipv4: core::net::Ipv4Addr::new(192, 168, 13, 1),
399 lease_ipv4: core::net::Ipv4Addr::new(192, 168, 13, 2),
400 lease_count: 1,
401 serve_dhcp: true,
402 sync_burst: false,
403 }
404 }
405
406 /// Override the AP/lease IPv4 addresses (must share a /24).
407 pub fn with_ipv4(mut self, ap: core::net::Ipv4Addr, lease: core::net::Ipv4Addr) -> Self {
408 self.ap_ipv4 = ap;
409 self.lease_ipv4 = lease;
410 self
411 }
412
413 /// Set the DHCP lease pool size (consecutive addresses from `lease_ipv4`).
414 pub fn with_lease_pool(mut self, count: u8) -> Self {
415 self.lease_count = count.max(1);
416 self
417 }
418
419 /// Lease address at `index` (`0` = `lease_ipv4`, `1` = next host, …).
420 pub fn lease_ip_at(&self, index: u8) -> core::net::Ipv4Addr {
421 let idx = index.min(self.lease_count.saturating_sub(1));
422 let mut oct = self.lease_ipv4.octets();
423 oct[3] = oct[3].saturating_add(idx);
424 core::net::Ipv4Addr::from(oct)
425 }
426
427 /// All configured pool addresses (up to [`Self::lease_count`]).
428 pub fn lease_pool(&self) -> heapless::Vec<core::net::Ipv4Addr, 8> {
429 let mut v = heapless::Vec::new();
430 for i in 0..self.lease_count.min(8) {
431 let _ = v.push(self.lease_ip_at(i));
432 }
433 v
434 }
435
436 /// Enable or disable the built-in DHCP server (default enabled).
437 pub fn with_dhcp_server(mut self, enabled: bool) -> Self {
438 self.serve_dhcp = enabled;
439 self
440 }
441
442 /// Fire one unicast frame back-to-back to every active lease per flood tick,
443 /// instead of unicasting round-robin (one lease per tick).
444 ///
445 /// All associated stations then receive their downlink PPDU within
446 /// microseconds of each other — synchronized multi-receiver CSI without the
447 /// round-robin spread. This is the workable substitute for a single broadcast
448 /// PPDU, which an ESP32 softAP can't reliably deliver (see [`Self::sync_burst`]).
449 /// Keep the DHCP server / lease pool enabled so stations associate as genuine
450 /// BSS members; only the per-tick transmit pattern changes.
451 pub fn with_sync_burst(mut self, enabled: bool) -> Self {
452 self.sync_burst = enabled;
453 self
454 }
455
456 /// Configured primary channel.
457 pub fn channel(&self) -> u8 {
458 self.channel
459 }
460
461 /// Configured HT40 secondary channel, or `None` for HT20.
462 pub fn secondary_channel(&self) -> Option<SecondaryChannel> {
463 self.secondary_channel
464 }
465}
466
467#[cfg(feature = "defmt")]
468impl defmt::Format for WifiApConfig {
469 fn format(&self, fmt: defmt::Formatter<'_>) {
470 defmt::write!(fmt, "WifiApConfig {{ ap_config: <opaque> }}");
471 }
472}
473
474// Enum for Central modes, each wrapping its specific config.
475
476/// Central node operational modes.
477pub enum CentralOpMode {
478 /// Drive an ESP-NOW exchange with a peripheral node.
479 EspNow(EspNowConfig),
480 /// Associate as a Wi-Fi station to harvest CSI from received frames.
481 WifiStation(WifiStationConfig),
482 /// Run a self-contained softAP CSI collector: start an access point (plus a
483 /// minimal DHCP server) so a [`CentralOpMode::WifiStation`] node can
484 /// associate and generate steady uplink traffic, captured as CSI on this AP.
485 WifiAccessPoint(WifiApConfig),
486 /// Fast one-to-one ESP-NOW collector (asymmetric simplex): broadcast a
487 /// sparse discovery beacon until a [`PeripheralOpMode::EspNowFastSource`] is
488 /// heard, then stop beaconing and go RX-only, capturing CSI from the source's
489 /// continuous unicast flood. Maximizes CSI packets/sec by leaving all airtime
490 /// to the single transmitter.
491 EspNowFastCollector(EspNowConfig),
492}
493
494// Enum for Peripheral modes, each wrapping its specific config.
495/// Peripheral node operational modes.
496pub enum PeripheralOpMode {
497 /// Reply to a central's ESP-NOW control frames.
498 EspNow(EspNowConfig),
499 /// Run as a Wi-Fi promiscuous sniffer; CSI is captured from every
500 /// frame received on the locked channel.
501 WifiSniffer(WifiSnifferConfig),
502 /// Fast one-to-one ESP-NOW source (asymmetric simplex): listen for a
503 /// [`CentralOpMode::EspNowFastCollector`] beacon, learn its MAC, then unicast
504 /// a continuous forced-PHY flood for the collector to capture as CSI.
505 EspNowFastSource(EspNowConfig),
506}
507
508/// High-level node type and mode.
509pub enum Node {
510 /// Run as the peripheral side of the chosen [`PeripheralOpMode`].
511 Peripheral(PeripheralOpMode),
512 /// Run as the central side of the chosen [`CentralOpMode`].
513 Central(CentralOpMode),
514}
515
516/// CSI collection behavior for the node.
517///
518/// Use `Listener` to keep CSI traffic flowing without processing packets,
519/// or `Collector` to actively process CSI data. Note: `Listener` combined with
520/// a sniffer node makes the sniffer effectively useless because no CSI data is
521/// processed.
522#[derive(PartialEq, Eq, Clone, Copy)]
523pub enum CollectionMode {
524 /// Enables CSI collection and processes CSI data.
525 Collector,
526 /// Enables CSI collection but does not process CSI data.
527 Listener,
528}
529
530/// Controls whether TX and RX tasks are active for a node.
531///
532/// Defaults to both TX and RX enabled.
533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
534pub struct IOTaskConfig {
535 /// Enable transmit-side task work for the selected operation mode.
536 pub tx_enabled: bool,
537 /// Enable receive/process-side task work for the selected operation mode.
538 pub rx_enabled: bool,
539}
540
541impl IOTaskConfig {
542 /// Create a task configuration with explicit TX/RX state.
543 pub const fn new(tx_enabled: bool, rx_enabled: bool) -> Self {
544 Self {
545 tx_enabled,
546 rx_enabled,
547 }
548 }
549}
550
551impl Default for IOTaskConfig {
552 fn default() -> Self {
553 Self::new(true, true)
554 }
555}
556
557/// Hardware handles required to operate a CSI node.
558pub struct CSINodeHardware<'a> {
559 interfaces: &'a mut Interfaces<'static>,
560 controller: &'a mut WifiController<'static>,
561}
562
563impl<'a> CSINodeHardware<'a> {
564 /// Create a hardware bundle from the Wi-Fi `Interfaces` and `WifiController`.
565 pub fn new(
566 interfaces: &'a mut Interfaces<'static>,
567 controller: &'a mut WifiController<'static>,
568 ) -> Self {
569 Self {
570 interfaces,
571 controller,
572 }
573 }
574}
575
576pub(crate) fn reset_globals() {
577 // Close all CSI delivery gates so any late-firing WiFi callback runs
578 // are no-ops, then clear the statistics counters. The CSI callback stays
579 // registered with esp-radio after stop (the radio itself is still up),
580 // but with the gates closed the callback short-circuits before it touches
581 // the log channel or the user's callback. Without this, sniffer/ESP-NOW/STA
582 // nodes keep emitting CSI lines on the serial port well after `send_stop()`.
583 crate::csi::delivery::reset();
584 crate::stats::reset();
585}
586
587/// Primary orchestration object for CSI collection.
588///
589/// Construct a node with `CSINode::new` or `CSINode::new_central_node`, configure
590/// optional protocol/rate/traffic frequency, then call `run()`.
591pub struct CSINode<'a> {
592 kind: Node,
593 collection_mode: CollectionMode,
594 io_tasks: IOTaskConfig,
595 /// CSI Configuration
596 csi_config: Option<CsiConfiguration>,
597 /// Traffic Generation Frequency
598 traffic_freq_hz: Option<u16>,
599 hardware: CSINodeHardware<'a>,
600 protocol: Option<Protocol>,
601 /// ICMP flood sends unsolicited echo replies (one-directional traffic)
602 /// instead of echo requests. See [`CSINode::set_flood_unsolicited_reply`].
603 flood_unsolicited_reply: bool,
604 /// Pluggable Wi-Fi bring-up back-end. Defaults to [`StandardProfile`];
605 /// override with [`CSINode::set_radio_profile`].
606 profile: &'static dyn RadioProfile,
607}
608
609impl<'a> CSINode<'a> {
610 /// Create a new node with explicit `Node` kind.
611 pub fn new(
612 kind: Node,
613 collection_mode: CollectionMode,
614 csi_config: Option<CsiConfiguration>,
615 traffic_freq_hz: Option<u16>,
616 hardware: CSINodeHardware<'a>,
617 ) -> Self {
618 Self {
619 kind,
620 collection_mode,
621 io_tasks: IOTaskConfig::default(),
622 csi_config,
623 traffic_freq_hz,
624 hardware,
625 protocol: None,
626 flood_unsolicited_reply: false,
627 profile: &StandardProfile,
628 }
629 }
630
631 /// Convenience constructor for a central node.
632 pub fn new_central_node(
633 op_mode: CentralOpMode,
634 collection_mode: CollectionMode,
635 csi_config: Option<CsiConfiguration>,
636 traffic_freq_hz: Option<u16>,
637 hardware: CSINodeHardware<'a>,
638 ) -> Self {
639 Self {
640 kind: Node::Central(op_mode),
641 collection_mode,
642 io_tasks: IOTaskConfig::default(),
643 csi_config,
644 traffic_freq_hz,
645 hardware,
646 protocol: None,
647 flood_unsolicited_reply: false,
648 profile: &StandardProfile,
649 }
650 }
651
652 /// Get the node type and operation mode.
653 pub fn get_node_type(&self) -> &Node {
654 &self.kind
655 }
656
657 /// Get the current collection mode.
658 pub fn get_collection_mode(&self) -> CollectionMode {
659 self.collection_mode
660 }
661
662 /// If central, return the active central op mode.
663 pub fn get_central_op_mode(&self) -> Option<&CentralOpMode> {
664 match &self.kind {
665 Node::Central(mode) => Some(mode),
666 Node::Peripheral(_) => None,
667 }
668 }
669
670 /// If peripheral, return the active peripheral op mode.
671 pub fn get_peripheral_op_mode(&self) -> Option<&PeripheralOpMode> {
672 match &self.kind {
673 Node::Peripheral(mode) => Some(mode),
674 Node::Central(_) => None,
675 }
676 }
677
678 /// Update CSI configuration.
679 pub fn set_csi_config(&mut self, config: CsiConfiguration) {
680 self.csi_config = Some(config);
681 }
682
683 /// Update Wi-Fi Station configuration (only applies to central station mode).
684 pub fn set_station_config(&mut self, config: WifiStationConfig) {
685 if let Node::Central(CentralOpMode::WifiStation(_)) = &mut self.kind {
686 self.kind = Node::Central(CentralOpMode::WifiStation(config));
687 }
688 }
689
690 /// Set traffic generation frequency in Hz (ESP-NOW modes).
691 pub fn set_traffic_frequency(&mut self, freq_hz: u16) {
692 self.traffic_freq_hz = Some(freq_hz);
693 }
694
695 /// Set collection mode for the node.
696 pub fn set_collection_mode(&mut self, mode: CollectionMode) {
697 self.collection_mode = mode;
698 }
699
700 /// Set TX/RX task enablement for the node.
701 pub fn set_io_tasks(&mut self, io_tasks: IOTaskConfig) {
702 self.io_tasks = io_tasks;
703 }
704
705 /// Enable or disable TX task work.
706 pub fn set_tx_enabled(&mut self, enabled: bool) {
707 self.io_tasks.tx_enabled = enabled;
708 }
709
710 /// Enable or disable RX task work.
711 pub fn set_rx_enabled(&mut self, enabled: bool) {
712 self.io_tasks.rx_enabled = enabled;
713 }
714
715 /// Get current TX/RX task configuration.
716 pub fn get_io_tasks(&self) -> IOTaskConfig {
717 self.io_tasks
718 }
719
720 /// Replace the node kind/mode.
721 pub fn set_op_mode(&mut self, mode: Node) {
722 self.kind = mode;
723 }
724
725 /// Set Wi-Fi protocol (overrides default).
726 pub fn set_protocol(&mut self, protocol: Protocol) {
727 self.protocol = Some(protocol);
728 }
729
730 /// Install a Wi-Fi bring-up profile (overrides the default
731 /// [`StandardProfile`]). Pass a reference to a zero-sized profile value,
732 /// e.g. `node.set_radio_profile(&MyProfile);`.
733 pub fn set_radio_profile(&mut self, profile: &'static dyn RadioProfile) {
734 self.profile = profile;
735 }
736
737 /// Make the ICMP traffic flood send unsolicited echo **replies** instead
738 /// of echo requests.
739 ///
740 /// The peer's IP stack silently ignores an unsolicited reply, so the
741 /// generated traffic becomes strictly one-directional: the peer still
742 /// hardware-ACKs every data frame (rate control stays fed) and captures
743 /// CSI per frame, but never transmits an IP-level response. This halves
744 /// the on-air frame count versus request/reply and stabilizes the offered
745 /// rate under CSMA contention. Trade-off: this node receives no CSI back
746 /// from the peer's replies.
747 pub fn set_flood_unsolicited_reply(&mut self, enabled: bool) {
748 self.flood_unsolicited_reply = enabled;
749 }
750
751 /// Set the ESP-NOW TX PHY rate after construction.
752 ///
753 /// Equivalent to [`EspNowConfig::with_phy_rate`]: forces the per-peer PHY
754 /// (and brings the radio up in started STA mode). Combine with
755 /// `EspNowConfig::with_ht40` for 40 MHz. No effect on non-ESP-NOW nodes —
756 /// STA / sniffer rates are driven by their own configuration, not here.
757 pub fn set_rate(&mut self, rate: WifiPhyRate) {
758 match &mut self.kind {
759 Node::Central(CentralOpMode::EspNow(cfg))
760 | Node::Central(CentralOpMode::EspNowFastCollector(cfg))
761 | Node::Peripheral(PeripheralOpMode::EspNow(cfg))
762 | Node::Peripheral(PeripheralOpMode::EspNowFastSource(cfg)) => {
763 cfg.phy_rate = rate;
764 cfg.force_phy = true;
765 }
766 _ => {}
767 }
768 }
769
770 /// Run the node for `duration` seconds with internal collection.
771 ///
772 /// This initializes Wi-Fi, configures CSI, and starts mode-specific tasks.
773 pub async fn run_duration(&mut self, duration: u64, client: &mut CSINodeClient) {
774 self.run_inner(Some(duration), Some(client)).await;
775 }
776
777 /// Shared implementation behind [`run`](Self::run) and
778 /// [`run_duration`](Self::run_duration).
779 ///
780 /// `duration`/`client` are `Some` only on the timed `run_duration` path:
781 /// when set, each mode arm runs an extra concurrent future that stops the
782 /// node after `duration` seconds (and, with RX enabled, drains CSI to the
783 /// logger via `client`). When `None` the node runs until externally
784 /// stopped via [`CSINodeClient::send_stop`].
785 async fn run_inner(&mut self, duration: Option<u64>, client: Option<&mut CSINodeClient>) {
786 let interfaces = &mut self.hardware.interfaces;
787 let controller = &mut self.hardware.controller;
788
789 // Applied every run (not only when set) so the process-wide flood-kind
790 // flag never leaks from a previous, differently-configured run.
791 crate::central::sta::set_icmp_flood_unsolicited(self.flood_unsolicited_reply);
792
793 // Take over esp-radio's ESP-NOW receive dispatcher *first*, before any
794 // other Wi-Fi reconfiguration runs (`set_protocols`, `set_csi`) — see
795 // `takeover_esp_now_recv` for why this must happen this early.
796 takeover_esp_now_recv(matches!(
797 &self.kind,
798 Node::Peripheral(PeripheralOpMode::WifiSniffer(_))
799 ));
800 // Let the freshly-constructed radio/ESP-NOW state settle before the
801 // first C5 reconfiguration mutation (no-op off C5).
802 c5_radio_settle().await;
803
804 let espnow_ht40 = matches!(
805 &self.kind,
806 Node::Peripheral(PeripheralOpMode::EspNow(c))
807 | Node::Peripheral(PeripheralOpMode::EspNowFastSource(c))
808 | Node::Central(CentralOpMode::EspNow(c))
809 | Node::Central(CentralOpMode::EspNowFastCollector(c))
810 if c.secondary_channel().is_some()
811 );
812
813 let is_ap = matches!(&self.kind, Node::Central(CentralOpMode::WifiAccessPoint(_)));
814
815 // Radio-profile back-end (Copy handle; does not alias `self.hardware`).
816 // `bringup` decides whether the profile takes over the extended Wi-Fi
817 // bring-up sequence for this node/protocol.
818 let profile = self.profile;
819 let bringup = profile.wants_bringup(&self.kind, self.protocol);
820
821 // Apply protocol before STA bring-up / CSI — on C5, recv must stay
822 // suspended across every controller mutation to avoid ISR WDT trips.
823 // Generic chip-level tuning lives in the radio profile; specialised
824 // back-ends may rebuild the set entirely.
825 if let Some(protocol) = self.protocol.take() {
826 let old_protocol = reconstruct_protocol(&protocol);
827 let base = Protocols::default().with_2_4(EnumSet::only(protocol));
828 let protocols = profile.tune_protocols(&self.kind, protocol, base);
829 with_espnow_recv_suspended(|| {
830 controller.set_protocols(protocols).unwrap();
831 });
832 self.protocol = Some(old_protocol);
833 c5_radio_settle().await;
834 }
835
836 if bringup {
837 with_espnow_recv_suspended(|| {
838 profile.apply_bandwidth(controller);
839 });
840 c5_radio_settle().await;
841 }
842
843 // Started STA mode is required for ESP-NOW CSI capture (RX path) and for
844 // forced-PHY / manual-unicast TX. On C5 dual-band, skip STA for TX-only
845 // broadcast (no peer_mac, no RX) — restarting STA there can wedge the
846 // Wi-Fi ISR when the TX loop starts immediately afterward.
847 // The fast simplex roles always need started STA: the collector for its
848 // RX/CSI path, the source for forced per-peer unicast PHY (which it
849 // applies after discovery, before any `peer_mac` is known).
850 let needs_sta_bringup = matches!(
851 &self.kind,
852 Node::Peripheral(PeripheralOpMode::EspNow(c)) | Node::Central(CentralOpMode::EspNow(c))
853 if self.io_tasks.rx_enabled
854 || {
855 #[cfg(not(feature = "esp32c5"))]
856 {
857 c.force_phy()
858 }
859 #[cfg(feature = "esp32c5")]
860 {
861 c.peer_mac().is_some()
862 }
863 }
864 ) || matches!(
865 &self.kind,
866 Node::Peripheral(PeripheralOpMode::EspNowFastSource(_))
867 | Node::Central(CentralOpMode::EspNowFastCollector(_))
868 );
869 if needs_sta_bringup {
870 with_espnow_recv_suspended(|| {
871 bring_up_espnow_sta(controller, false);
872 });
873 // The STA restart is the riskiest C5 op — settle before the next
874 // mutation (set_csi) so a post-restart MAC interrupt can drain.
875 c5_radio_settle().await;
876 }
877
878 // Tasks Necessary for Central Station & Sniffer
879 let sta_interface = if let Node::Central(CentralOpMode::WifiStation(config)) = &self.kind {
880 #[cfg(feature = "esp32c5")]
881 if let Some(channel) = config.channel_hint {
882 with_espnow_recv_suspended(|| {
883 apply_espnow_band_for_channel(controller, channel);
884 });
885 c5_radio_settle().await;
886 }
887 Some(sta_init(
888 &mut interfaces.station,
889 config,
890 controller,
891 profile,
892 bringup,
893 ))
894 } else {
895 None
896 };
897 if bringup && sta_interface.is_some() {
898 with_espnow_recv_suspended(|| {
899 profile.apply_protocols_post(controller);
900 });
901 c5_radio_settle().await;
902 }
903
904 // Self-contained softAP: bring up the AP-side embassy-net stack (static
905 // IP) and apply the AP config to the controller. `interfaces.access_point`
906 // is disjoint from `.station`/`.esp_now`/`.sniffer`, so this borrow is fine.
907 let ap_interface = if let Node::Central(CentralOpMode::WifiAccessPoint(config)) = &self.kind {
908 #[cfg(feature = "esp32c5")]
909 if config.secondary_channel().is_none() {
910 with_espnow_recv_suspended(|| {
911 apply_espnow_band_for_channel(controller, config.channel());
912 });
913 }
914 if let Some(secondary) = config.secondary_channel() {
915 with_espnow_recv_suspended(|| {
916 apply_espnow_ht40_mode(controller, config.channel(), secondary);
917 });
918 c5_radio_settle().await;
919 }
920 let ifaces = ap_init(
921 &mut interfaces.access_point,
922 config,
923 controller,
924 profile,
925 bringup,
926 );
927 if bringup {
928 with_espnow_recv_suspended(|| {
929 profile.apply_protocols_post(controller);
930 });
931 }
932 // The AP `set_config` restarts the radio; settle before `set_csi`.
933 c5_radio_settle().await;
934 Some(ifaces)
935 } else {
936 None
937 };
938
939 // Build CSI Configuration
940 let mut config = match self.csi_config {
941 Some(ref config) => {
942 log_ln!("CSI Configuration Set: {:?}", config);
943 build_csi_config(config)
944 }
945 None => {
946 let default_config = CsiConfiguration::default();
947 log_ln!(
948 "No CSI Configuration Provided. Going with defaults: {:?}",
949 default_config
950 );
951 build_csi_config(&default_config)
952 }
953 };
954 // Let the radio profile enable any extra acquisition modes it needs
955 // (default is a no-op) before the config is registered/cloned.
956 profile.tune_csi_acquisition(&mut config);
957
958 // Apply Protocol if specified — handled above (before STA bring-up).
959
960 log_ln!("Wi-Fi Controller Started");
961 let is_collector = self.collection_mode == CollectionMode::Collector;
962 IS_COLLECTOR.store(is_collector, Ordering::Relaxed);
963 set_seq_drop_detection(matches!(
964 &self.kind,
965 Node::Peripheral(PeripheralOpMode::EspNow(_))
966 | Node::Peripheral(PeripheralOpMode::EspNowFastSource(_))
967 | Node::Central(CentralOpMode::EspNow(_))
968 | Node::Central(CentralOpMode::EspNowFastCollector(_))
969 ));
970
971 // Set Peripheral/Central to Collect CSI. Keep a clone so the STA
972 // recovery path in run_sta_connect can re-apply after a stop/start
973 // cycle (stop clears the CSI filter/callback).
974 //
975 // Only register the CSI callback when RX is actually enabled —
976 // otherwise the radio fires `capture_csi_info` for every overheard
977 // 802.11 frame (beacons, neighbour ESP-NOW, retries) on the WiFi
978 // task hot path, stealing cycles from the central TX-completion
979 // ISR for no purpose.
980 let csi_config_for_recovery = config.clone();
981 let is_sniffer = matches!(
982 &self.kind,
983 Node::Peripheral(PeripheralOpMode::WifiSniffer(_))
984 );
985 // AP is excluded here: `set_config(AccessPoint)` in `ap_init` restarts the
986 // radio and clears the CSI filter, so the AP arm registers CSI itself
987 // after start.
988 if self.io_tasks.rx_enabled && !is_sniffer && !espnow_ht40 && !is_ap {
989 with_espnow_recv_suspended(|| {
990 set_csi(controller, config.clone());
991 });
992 // Settle after enabling CSI before the mode task issues its first
993 // set_channel / TX so the run loop doesn't start into a pending IRQ.
994 c5_radio_settle().await;
995 }
996 let rx_enabled = self.io_tasks.rx_enabled;
997 // Immutable borrow of a *different* `interfaces` field than the ESP-NOW
998 // arms touch (`esp_now` / `station`), so this disjoint borrow is fine.
999 // Used by the sniffer arm and to clear promiscuous mode on WifiStation
1000 // shutdown.
1001 let sniffer = &interfaces.sniffer;
1002
1003 // Initialize Nodes based on type
1004 match &self.kind {
1005 Node::Peripheral(op_mode) => match op_mode {
1006 PeripheralOpMode::EspNow(esp_now_config) => {
1007 // Initialize as Peripheral node with EspNowConfig
1008 // Non-HT40 path on dual-band C5: select band from primary
1009 // channel as well, so a prior 5 GHz app doesn't leave this
1010 // run pinned to 5 GHz when channel is 2.4 GHz (e.g. ch 11).
1011 #[cfg(feature = "esp32c5")]
1012 if esp_now_config.secondary_channel().is_none() {
1013 with_espnow_recv_suspended(|| {
1014 apply_espnow_band_for_channel(controller, esp_now_config.channel());
1015 });
1016 }
1017 // HT40: set the secondary channel before the run loop (which
1018 // then skips its own `esp_now.set_channel`). The TX rate/PHY
1019 // is forced per-peer inside the run loops (see
1020 // `set_peer_espnow_phy`); `esp_now.set_rate` is unused — it
1021 // routes to the deprecated `esp_wifi_config_espnow_rate`.
1022 if let Some(secondary) = esp_now_config.secondary_channel() {
1023 with_espnow_recv_suspended(|| {
1024 apply_espnow_ht40_mode(controller, esp_now_config.channel(), secondary);
1025 });
1026 install_static_espnow_recv();
1027 c5_radio_settle().await;
1028 if rx_enabled {
1029 with_espnow_recv_suspended(|| {
1030 set_csi(controller, config.clone());
1031 });
1032 c5_radio_settle().await;
1033 }
1034 }
1035
1036 let main_task = run_esp_now_peripheral(
1037 &mut interfaces.esp_now,
1038 esp_now_config,
1039 self.traffic_freq_hz,
1040 self.io_tasks,
1041 );
1042 drive_main(main_task, rx_enabled, duration, client).await;
1043 }
1044 PeripheralOpMode::EspNowFastSource(esp_now_config) => {
1045 // Pre-setup mirrors the ESP-NOW peripheral arm above.
1046 #[cfg(feature = "esp32c5")]
1047 if esp_now_config.secondary_channel().is_none() {
1048 with_espnow_recv_suspended(|| {
1049 apply_espnow_band_for_channel(controller, esp_now_config.channel());
1050 });
1051 }
1052 if let Some(secondary) = esp_now_config.secondary_channel() {
1053 with_espnow_recv_suspended(|| {
1054 apply_espnow_ht40_mode(controller, esp_now_config.channel(), secondary);
1055 });
1056 install_static_espnow_recv();
1057 c5_radio_settle().await;
1058 if rx_enabled {
1059 with_espnow_recv_suspended(|| {
1060 set_csi(controller, config.clone());
1061 });
1062 c5_radio_settle().await;
1063 }
1064 }
1065
1066 let main_task = run_esp_now_fast_source(
1067 &mut interfaces.esp_now,
1068 esp_now_config,
1069 self.traffic_freq_hz,
1070 self.io_tasks,
1071 );
1072 drive_main(main_task, rx_enabled, duration, client).await;
1073 }
1074 PeripheralOpMode::WifiSniffer(sniffer_config) => {
1075 #[cfg(feature = "esp32c5")]
1076 {
1077 let band = if sniffer_config.channel() >= 36 {
1078 BandMode::_5G
1079 } else {
1080 BandMode::_2_4G
1081 };
1082 controller.set_band_mode(band).unwrap();
1083 }
1084 sniffer.set_promiscuous_mode(true).unwrap();
1085 controller
1086 .set_channel(sniffer_config.channel(), SecondaryChannel::None)
1087 .unwrap();
1088 if bringup {
1089 with_espnow_recv_suspended(|| {
1090 profile.apply_sniffer_radio(controller);
1091 });
1092 c5_radio_settle().await;
1093 }
1094 if rx_enabled {
1095 set_csi(controller, config.clone());
1096 }
1097 // ESP-NOW's heap-allocating `rcv_cb` was already dropped at
1098 // the top of `run_inner` via `takeover_esp_now_recv`, so
1099 // overheard vendor frames are discarded at the C layer.
1100 //
1101 // The sniffer arm has no `main_task`, so it drives CSI
1102 // collection directly rather than through `drive_main`.
1103 match (duration, rx_enabled) {
1104 (Some(d), true) => {
1105 join(
1106 run_process_csi_packet(),
1107 csi_data_collection(client.unwrap(), d),
1108 )
1109 .await;
1110 // `csi_data_collection` signals stop, so the join
1111 // returns; this trailing await lets the rate task
1112 // observe the stop and exit (preserves prior behavior).
1113 run_process_csi_packet().await;
1114 }
1115 (Some(d), false) => stop_after_duration(d).await,
1116 (None, true) => run_process_csi_packet().await,
1117 (None, false) => wait_for_stop().await,
1118 }
1119 sniffer.set_promiscuous_mode(false).unwrap();
1120 }
1121 },
1122 Node::Central(op_mode) => match op_mode {
1123 CentralOpMode::EspNow(esp_now_config) => {
1124 // Initialize as Central node with EspNowConfig.
1125 // Non-HT40 path on dual-band C5: select band from primary
1126 // channel as well, so a prior 5 GHz app doesn't leave this
1127 // run pinned to 5 GHz when channel is 2.4 GHz (e.g. ch 11).
1128 #[cfg(feature = "esp32c5")]
1129 if esp_now_config.secondary_channel().is_none() {
1130 with_espnow_recv_suspended(|| {
1131 apply_espnow_band_for_channel(controller, esp_now_config.channel());
1132 });
1133 }
1134 // HT40 handling mirrors the peripheral ESP-NOW arm above.
1135 if let Some(secondary) = esp_now_config.secondary_channel() {
1136 with_espnow_recv_suspended(|| {
1137 apply_espnow_ht40_mode(controller, esp_now_config.channel(), secondary);
1138 });
1139 install_static_espnow_recv();
1140 c5_radio_settle().await;
1141 if rx_enabled {
1142 with_espnow_recv_suspended(|| {
1143 set_csi(controller, config.clone());
1144 });
1145 c5_radio_settle().await;
1146 }
1147 }
1148
1149 let main_task = run_esp_now_central(
1150 &mut interfaces.esp_now,
1151 interfaces.station.mac_address(),
1152 esp_now_config,
1153 self.traffic_freq_hz,
1154 is_collector,
1155 self.io_tasks,
1156 );
1157 drive_main(main_task, rx_enabled, duration, client).await;
1158 }
1159 CentralOpMode::EspNowFastCollector(esp_now_config) => {
1160 // Pre-setup mirrors the ESP-NOW central arm above.
1161 #[cfg(feature = "esp32c5")]
1162 if esp_now_config.secondary_channel().is_none() {
1163 with_espnow_recv_suspended(|| {
1164 apply_espnow_band_for_channel(controller, esp_now_config.channel());
1165 });
1166 }
1167 if let Some(secondary) = esp_now_config.secondary_channel() {
1168 with_espnow_recv_suspended(|| {
1169 apply_espnow_ht40_mode(controller, esp_now_config.channel(), secondary);
1170 });
1171 install_static_espnow_recv();
1172 c5_radio_settle().await;
1173 if rx_enabled {
1174 with_espnow_recv_suspended(|| {
1175 set_csi(controller, config.clone());
1176 });
1177 c5_radio_settle().await;
1178 }
1179 }
1180
1181 let main_task = run_esp_now_fast_collector(
1182 &mut interfaces.esp_now,
1183 esp_now_config,
1184 self.io_tasks,
1185 );
1186 drive_main(main_task, rx_enabled, duration, client).await;
1187 }
1188 CentralOpMode::WifiAccessPoint(ap_config) => {
1189 // Start the AP, run the net stack + optional DHCP server, and
1190 // collect CSI from associated stations' uplink frames. CSI is
1191 // registered inside `run_ap` (after the AP-start radio restart).
1192 let (ap_stack, ap_runner) = ap_interface.unwrap();
1193 let main_task = run_ap(
1194 controller,
1195 ap_stack,
1196 ap_runner,
1197 ap_config,
1198 csi_config_for_recovery,
1199 self.io_tasks,
1200 self.traffic_freq_hz,
1201 );
1202 drive_main(main_task, rx_enabled, duration, client).await;
1203 sniffer.set_promiscuous_mode(false).unwrap();
1204 }
1205 CentralOpMode::WifiStation(_sta_config) => {
1206 // Initialize as Wifi Station Collector with WifiStationConfig
1207 // 1. Connect to Wi-Fi network, etc.
1208 // 2. Run DHCP, NTP sync if enabled in config, etc.
1209 // 3. Spawn STA Connection Handling Task
1210 // 4. Spawn STA Network Operation Task
1211 let (sta_stack, sta_runner) = sta_interface.unwrap();
1212
1213 let main_task = run_sta_connect(
1214 controller,
1215 self.traffic_freq_hz,
1216 sta_stack,
1217 sta_runner,
1218 csi_config_for_recovery,
1219 self.io_tasks,
1220 );
1221 drive_main(main_task, rx_enabled, duration, client).await;
1222 // Clear promiscuous mode on shutdown. It is never enabled on
1223 // a STA interface, so this is a no-op — kept to match the
1224 // unconditional shutdown path the untimed `run()` always took.
1225 sniffer.set_promiscuous_mode(false).unwrap();
1226 }
1227 },
1228 }
1229
1230 STOP_SIGNAL.reset();
1231 reset_globals();
1232 }
1233
1234 /// Run the node until stopped.
1235 ///
1236 /// This initializes Wi-Fi, configures CSI, and starts mode-specific tasks.
1237 pub async fn run(&mut self) {
1238 self.run_inner(None, None).await;
1239 }
1240}
1241
1242/// Concurrent driver for a mode's `main_task`.
1243///
1244/// Joins `main_task` with the CSI rate task (RX enabled) or a stop waiter, and
1245/// — on the timed `run_duration` path (`duration`/`client` are `Some`) — a
1246/// third future that ends the run after `duration` seconds, draining CSI to the
1247/// logger via `client` when RX is enabled.
1248async fn drive_main(
1249 main_task: impl core::future::Future,
1250 rx_enabled: bool,
1251 duration: Option<u64>,
1252 client: Option<&mut CSINodeClient>,
1253) {
1254 match (duration, rx_enabled) {
1255 (Some(d), true) => {
1256 join3(
1257 main_task,
1258 run_process_csi_packet(),
1259 csi_data_collection(client.unwrap(), d),
1260 )
1261 .await;
1262 }
1263 (Some(d), false) => {
1264 join3(main_task, wait_for_stop(), stop_after_duration(d)).await;
1265 }
1266 (None, true) => {
1267 join(main_task, run_process_csi_packet()).await;
1268 }
1269 (None, false) => {
1270 join(main_task, wait_for_stop()).await;
1271 }
1272 }
1273}
1274
1275fn reconstruct_protocol(protocol: &Protocol) -> Protocol {
1276 *protocol
1277}