Skip to main content

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}